[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 <sebastian@calyptus.eu>
Hendrik Liebau committed
Mar 5, 2023 at 04:56 UTC
f905da22762369975be71da5c13ff96796773039
5 files changed
+158
-5
fixtures/flight/src/Button.js
+12
-2
@@ -3,11 +3,21 @@
3
import * as React from 'react';
4
5
export default function Button({action, children}) {
6
+ const [isPending, setIsPending] = React.useState(false);
7
+
8
return (
9
<button
10
+ disabled={isPending}
11
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
+ }
21
}}>
22
{children}
23
</button>
fixtures/flight/src/actions.js
+9
-2
@@ -1,6 +1,13 @@
1
'use server';
2
3
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
+ );
13
}
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+68
@@ -1046,4 +1046,72 @@ describe('ReactFlightDOM', () => {
1046
});
1047
expect(container.innerHTML).toBe('<p>async hello</p>');
1048
});
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
+ });
1117
});
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+64
@@ -850,4 +850,68 @@ describe('ReactFlightDOMBrowser', () => {
850
const result = await actionProxy('!');
851
expect(result).toBe('Hello World!');
852
});
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
+ });
917
});
packages/react-server/src/ReactFlightServer.js
+5
-1
@@ -285,7 +285,8 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
285
pingTask(request, newTask);
286
},
287
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
290
const digest = logRecoverableError(request, reason);
291
if (__DEV__) {
292
const {message, stack} = getErrorMessageAndStackDev(reason);
@@ -293,6 +294,9 @@ function serializeThenable(request: Request, thenable: Thenable<any>): number {
294
} else {
295
emitErrorChunkProd(request, newTask.id, digest);
296
}
297
+ if (request.destination !== null) {
298
+ flushCompletedChunks(request, request.destination);
299
+ }
300
},
301
);
302