Skip to content

Commit f905da2

Browse files
[Flight] Send server reference error chunks to the client (#26293)
Previously when a called server reference function was rejected, the emitted error chunk was not flushed, and the request was not properly closed. Co-authored-by: Sebastian Markbage <[email protected]>
1 parent e0241b6 commit f905da2

File tree

5 files changed

+158
-5
lines changed

5 files changed

+158
-5
lines changed

fixtures/flight/src/Button.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@
33
import * as React from 'react';
44

55
export default function Button({action, children}) {
6+
const [isPending, setIsPending] = React.useState(false);
7+
68
return (
79
<button
10+
disabled={isPending}
811
onClick={async () => {
9-
const result = await action();
10-
console.log(result);
12+
setIsPending(true);
13+
try {
14+
const result = await action();
15+
console.log(result);
16+
} catch (error) {
17+
console.error(error);
18+
} finally {
19+
setIsPending(false);
20+
}
1121
}}>
1222
{children}
1323
</button>

fixtures/flight/src/actions.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
'use server';
22

33
export async function like() {
4-
console.log('Like');
5-
return 'Liked';
4+
return new Promise((resolve, reject) =>
5+
setTimeout(
6+
() =>
7+
Math.random() > 0.5
8+
? resolve('Liked')
9+
: reject(new Error('Failed to like')),
10+
500
11+
)
12+
);
613
}

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,4 +1046,72 @@ describe('ReactFlightDOM', () => {
10461046
});
10471047
expect(container.innerHTML).toBe('<p>async hello</p>');
10481048
});
1049+
1050+
// @gate enableUseHook
1051+
it('should throw on the client if a passed promise eventually rejects', async () => {
1052+
const reportedErrors = [];
1053+
const theError = new Error('Server throw');
1054+
1055+
async function getData() {
1056+
throw theError;
1057+
}
1058+
1059+
function Component({data}) {
1060+
const text = use(data);
1061+
return <p>{text}</p>;
1062+
}
1063+
1064+
const ClientComponent = clientExports(Component);
1065+
1066+
function ServerComponent() {
1067+
const data = getData(); // no await here
1068+
return <ClientComponent data={data} />;
1069+
}
1070+
1071+
function Await({response}) {
1072+
return use(response);
1073+
}
1074+
1075+
function App({response}) {
1076+
return (
1077+
<Suspense fallback={<h1>Loading...</h1>}>
1078+
<ErrorBoundary
1079+
fallback={e => (
1080+
<p>
1081+
{__DEV__ ? e.message + ' + ' : null}
1082+
{e.digest}
1083+
</p>
1084+
)}>
1085+
<Await response={response} />
1086+
</ErrorBoundary>
1087+
</Suspense>
1088+
);
1089+
}
1090+
1091+
const {writable, readable} = getTestStream();
1092+
const {pipe} = ReactServerDOMWriter.renderToPipeableStream(
1093+
<ServerComponent />,
1094+
webpackMap,
1095+
{
1096+
onError(x) {
1097+
reportedErrors.push(x);
1098+
return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
1099+
},
1100+
},
1101+
);
1102+
pipe(writable);
1103+
const response = ReactServerDOMReader.createFromReadableStream(readable);
1104+
1105+
const container = document.createElement('div');
1106+
const root = ReactDOMClient.createRoot(container);
1107+
await act(async () => {
1108+
root.render(<App response={response} />);
1109+
});
1110+
expect(container.innerHTML).toBe(
1111+
__DEV__
1112+
? '<p>Server throw + a dev digest</p>'
1113+
: '<p>digest("Server throw")</p>',
1114+
);
1115+
expect(reportedErrors).toEqual([theError]);
1116+
});
10491117
});

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,4 +850,68 @@ describe('ReactFlightDOMBrowser', () => {
850850
const result = await actionProxy('!');
851851
expect(result).toBe('Hello World!');
852852
});
853+
854+
it('propagates server reference errors to the client', async () => {
855+
let actionProxy;
856+
857+
function Client({action}) {
858+
actionProxy = action;
859+
return 'Click Me';
860+
}
861+
862+
async function send(text) {
863+
return Promise.reject(new Error(`Error for ${text}`));
864+
}
865+
866+
const ServerModule = serverExports({send});
867+
const ClientRef = clientExports(Client);
868+
869+
const stream = ReactServerDOMWriter.renderToReadableStream(
870+
<ClientRef action={ServerModule.send} />,
871+
webpackMap,
872+
);
873+
874+
const response = ReactServerDOMReader.createFromReadableStream(stream, {
875+
async callServer(ref, args) {
876+
const fn = requireServerRef(ref);
877+
return ReactServerDOMReader.createFromReadableStream(
878+
ReactServerDOMWriter.renderToReadableStream(
879+
fn.apply(null, args),
880+
null,
881+
{onError: error => 'test-error-digest'},
882+
),
883+
);
884+
},
885+
});
886+
887+
function App() {
888+
return use(response);
889+
}
890+
891+
const container = document.createElement('div');
892+
const root = ReactDOMClient.createRoot(container);
893+
await act(async () => {
894+
root.render(<App />);
895+
});
896+
897+
if (__DEV__) {
898+
await expect(actionProxy('test')).rejects.toThrow('Error for test');
899+
} else {
900+
let thrownError;
901+
902+
try {
903+
await actionProxy('test');
904+
} catch (error) {
905+
thrownError = error;
906+
}
907+
908+
expect(thrownError).toEqual(
909+
new Error(
910+
'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.',
911+
),
912+
);
913+
914+
expect(thrownError.digest).toBe('test-error-digest');
915+
}
916+
});
853917
});

packages/react-server/src/ReactFlightServer.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,14 +285,18 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
285285
pingTask(request, newTask);
286286
},
287287
reason => {
288-
// TODO: Is it safe to directly emit these without being inside a retry?
288+
newTask.status = ERRORED;
289+
// TODO: We should ideally do this inside performWork so it's scheduled
289290
const digest = logRecoverableError(request, reason);
290291
if (__DEV__) {
291292
const {message, stack} = getErrorMessageAndStackDev(reason);
292293
emitErrorChunkDev(request, newTask.id, digest, message, stack);
293294
} else {
294295
emitErrorChunkProd(request, newTask.id, digest);
295296
}
297+
if (request.destination !== null) {
298+
flushCompletedChunks(request, request.destination);
299+
}
296300
},
297301
);
298302

0 commit comments

Comments
 (0)