A tool for people curious about the React Server Components protocol

more error handling

+20
src/client/runtime/steppable-stream.ts
··· 28 28 releasedCount = 0; 29 29 buffered = false; 30 30 closed = false; 31 + error: Error | null = null; 31 32 release: (count: number) => void; 32 33 flightPromise: Thenable<unknown>; 33 34 bufferPromise: Promise<void>; ··· 83 84 84 85 partial += decoder.decode(); 85 86 if (partial.trim()) this.rows.push(partial); 87 + } catch (err) { 88 + this.error = err instanceof Error ? err : new Error(String(err)); 86 89 } finally { 87 90 this.buffered = true; 88 91 } ··· 90 93 91 94 async waitForBuffer(): Promise<void> { 92 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; 93 113 } 94 114 }
+2
src/client/runtime/timeline.ts
··· 10 10 args?: string; 11 11 rows: string[]; 12 12 flightPromise: Thenable<unknown> | undefined; 13 + error: Error | null; 13 14 chunkStart: number; 14 15 chunkCount: number; 15 16 canDelete: boolean; ··· 57 58 const base = { 58 59 rows: entry.stream.rows, 59 60 flightPromise: entry.stream.flightPromise, 61 + error: entry.stream.error, 60 62 chunkStart, 61 63 chunkCount, 62 64 canDelete: this.cursor <= chunkStart,
+37
src/client/samples.ts
··· 576 576 return String(v) 577 577 }`, 578 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 + }, 579 616 cve: { 580 617 name: "CVE-2025-55182", 581 618 server: `import { Instructions } from './client'
+20
src/client/ui/FlightLog.css
··· 84 84 opacity: 0.4; 85 85 } 86 86 87 + .FlightLog-entry--error { 88 + border-left-color: #e57373; 89 + } 90 + 87 91 .FlightLog-entry-header { 88 92 display: flex; 89 93 align-items: center; ··· 143 147 color: #81c784; 144 148 white-space: pre-wrap; 145 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; 146 166 } 147 167 148 168 /* FlightLog-renderView */
+39 -15
src/client/ui/FlightLog.tsx
··· 1 - import React, { useState, useRef, useEffect } from "react"; 1 + import React, { useState, useRef, useEffect, useTransition } from "react"; 2 2 import { FlightTreeView } from "./TreeView.tsx"; 3 3 import { Select } from "./Select.tsx"; 4 4 import type { EntryView } from "../runtime/index.ts"; ··· 79 79 cursor, 80 80 onDelete, 81 81 }: FlightLogEntryProps): React.ReactElement { 82 - const modifierClass = entry.isActive 83 - ? "FlightLog-entry--active" 84 - : entry.isDone 85 - ? "FlightLog-entry--done" 86 - : "FlightLog-entry--pending"; 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"; 87 90 88 91 return ( 89 92 <div className={`FlightLog-entry ${modifierClass}`} data-testid="flight-entry"> ··· 109 112 <pre className="FlightLog-entry-requestArgs">{entry.args}</pre> 110 113 </div> 111 114 )} 112 - <RenderLogView entry={entry} cursor={cursor} /> 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 + )} 113 122 </div> 114 123 ); 115 124 } ··· 118 127 entries: EntryView[]; 119 128 cursor: number; 120 129 availableActions: string[]; 121 - onAddRawAction: (actionName: string, rawPayload: string) => void; 130 + onAddRawAction: (actionName: string, rawPayload: string) => Promise<void>; 122 131 onDeleteEntry: (index: number) => void; 123 132 }; 124 133 ··· 133 142 const [showRawInput, setShowRawInput] = useState(false); 134 143 const [selectedAction, setSelectedAction] = useState(""); 135 144 const [rawPayload, setRawPayload] = useState(""); 145 + const [isPending, startTransition] = useTransition(); 136 146 137 147 const handleAddRaw = (): void => { 138 148 if (rawPayload.trim()) { 139 - onAddRawAction(selectedAction, rawPayload); 140 - setSelectedAction(availableActions[0] ?? ""); 141 - setRawPayload(""); 142 - setShowRawInput(false); 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 + }); 143 161 } 144 162 }; 145 163 ··· 164 182 {availableActions.length > 0 && 165 183 (showRawInput ? ( 166 184 <div className="FlightLog-rawForm"> 167 - <Select value={selectedAction} onChange={(e) => setSelectedAction(e.target.value)}> 185 + <Select 186 + value={selectedAction} 187 + onChange={(e) => setSelectedAction(e.target.value)} 188 + disabled={isPending} 189 + > 168 190 {availableActions.map((action) => ( 169 191 <option key={action} value={action}> 170 192 {action} ··· 177 199 onChange={(e) => setRawPayload(e.target.value)} 178 200 className="FlightLog-rawForm-textarea" 179 201 rows={6} 202 + disabled={isPending} 180 203 /> 181 204 <div className="FlightLog-rawForm-buttons"> 182 205 <button 183 206 className="FlightLog-rawForm-submitBtn" 184 207 onClick={handleAddRaw} 185 - disabled={!rawPayload.trim()} 208 + disabled={!rawPayload.trim() || isPending} 186 209 > 187 - Add 210 + {isPending ? "Adding..." : "Add"} 188 211 </button> 189 212 <button 190 213 className="FlightLog-rawForm-cancelBtn" 191 214 onClick={() => setShowRawInput(false)} 215 + disabled={isPending} 192 216 > 193 217 Cancel 194 218 </button>
+20 -5
src/client/workspace-session.ts
··· 86 86 args: EncodedArgs, 87 87 argsDisplay: string, 88 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(); 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 + } 94 106 this.timeline.addAction(actionName, argsDisplay, stream); 107 + if (stream.error) { 108 + throw stream.error; 109 + } 95 110 return stream; 96 111 } 97 112
+6 -2
src/server/worker-server.ts
··· 105 105 } 106 106 export type Deploy = typeof deploy; 107 107 108 + const renderOptions = { 109 + onError: () => "Switch to dev mode (top right) to see the full error.", 110 + }; 111 + 108 112 function render(): ReadableStream<Uint8Array> { 109 113 if (!deployed) throw new Error("No code deployed"); 110 114 const App = deployed.module.default as React.ComponentType; 111 - return renderToReadableStream(React.createElement(App), deployed.manifest); 115 + return renderToReadableStream(React.createElement(App), deployed.manifest, renderOptions); 112 116 } 113 117 export type Render = typeof render; 114 118 ··· 136 140 const args = Array.isArray(decoded) ? decoded : [decoded]; 137 141 const result = await actionFn(...args); 138 142 139 - return renderToReadableStream(result, deployed.manifest); 143 + return renderToReadableStream(result, deployed.manifest, renderOptions); 140 144 } 141 145 export type CallAction = typeof callAction; 142 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 + });