+20
src/client/runtime/steppable-stream.ts
+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
+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
+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
+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
+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
+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
+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
+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
+
});