@samitouri / QOS-React-2 / commits / f905da2276

[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