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

[Fizz] Support abort reasons (#24680)

* [Fizz] Support abort reasons Fizz supports aborting the render but does not currently accept a reason. The various render functions that use Fizz have some automatic and some user-controlled abort semantics that can be useful to communicate with the running program and users about why an Abort happened. This change implements abort reasons for renderToReadableStream and renderToPipeable stream as well as legacy renderers such as renderToString and related implementations. For AbortController implementations the reason passed to the abort method is forwarded to Fizz and sent to the onError handler. If no reason is provided the AbortController should construct an AbortError DOMException and as a fallback Fizz will generate a similar error in the absence of a reason For pipeable streams, an abort function is returned alongside pipe which already accepted a reason. That reason is now forwarded to Fizz and the implementation described above. For legacy renderers there is no exposed abort functionality but it is used internally and the reasons provided give useful context to, for instance to the fact that Suspense is not supported in renderToString-like renderers

Josh Story committed Jun 7, 2022 at 22:36 UTC b345523528f6b346c3a6de2145d5df6b2e23ca75
17 files changed +950 -422
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+179 -1
@@ -1106,7 +1106,13 @@ describe('ReactDOMFizzServer', () => {
1106 expect(Scheduler).toFlushAndYield([]);
1107 expectErrors(
1108 errors,
1109 - [['This Suspense boundary was aborted by the server.', expectedDigest]],
1109 + [
1110 + [
1111 + 'The server did not finish this Suspense boundary: The render was aborted by the server without a reason.',
1112 + expectedDigest,
1113 + componentStack(['h1', 'Suspense', 'div', 'App']),
1114 + ],
1115 + ],
1116 [
1117 [
1118 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
@@ -3057,6 +3063,178 @@ describe('ReactDOMFizzServer', () => {
3063 );
3064 });
3065
3066 + // @gate experimental
3067 + it('Supports custom abort reasons with a string', async () => {
3068 + function App() {
3069 + return (
3070 + <div>
3071 + <p>
3072 + <Suspense fallback={'p'}>
3073 + <AsyncText text={'hello'} />
3074 + </Suspense>
3075 + </p>
3076 + <span>
3077 + <Suspense fallback={'span'}>
3078 + <AsyncText text={'world'} />
3079 + </Suspense>
3080 + </span>
3081 + </div>
3082 + );
3083 + }
3084 +
3085 + let abort;
3086 + const loggedErrors = [];
3087 + await act(async () => {
3088 + const {
3089 + pipe,
3090 + abort: abortImpl,
3091 + } = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3092 + onError(error) {
3093 + // In this test we contrive erroring with strings so we push the error whereas in most
3094 + // other tests we contrive erroring with Errors and push the message.
3095 + loggedErrors.push(error);
3096 + return 'a digest';
3097 + },
3098 + });
3099 + abort = abortImpl;
3100 + pipe(writable);
3101 + });
3102 +
3103 + expect(loggedErrors).toEqual([]);
3104 + expect(getVisibleChildren(container)).toEqual(
3105 + <div>
3106 + <p>p</p>
3107 + <span>span</span>
3108 + </div>,
3109 + );
3110 +
3111 + await act(() => {
3112 + abort('foobar');
3113 + });
3114 +
3115 + expect(loggedErrors).toEqual(['foobar', 'foobar']);
3116 +
3117 + const errors = [];
3118 + ReactDOMClient.hydrateRoot(container, <App />, {
3119 + onRecoverableError(error, errorInfo) {
3120 + errors.push({error, errorInfo});
3121 + },
3122 + });
3123 +
3124 + expect(Scheduler).toFlushAndYield([]);
3125 +
3126 + expectErrors(
3127 + errors,
3128 + [
3129 + [
3130 + 'The server did not finish this Suspense boundary: foobar',
3131 + 'a digest',
3132 + componentStack(['Suspense', 'p', 'div', 'App']),
3133 + ],
3134 + [
3135 + 'The server did not finish this Suspense boundary: foobar',
3136 + 'a digest',
3137 + componentStack(['Suspense', 'span', 'div', 'App']),
3138 + ],
3139 + ],
3140 + [
3141 + [
3142 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3143 + 'a digest',
3144 + ],
3145 + [
3146 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3147 + 'a digest',
3148 + ],
3149 + ],
3150 + );
3151 + });
3152 +
3153 + // @gate experimental
3154 + it('Supports custom abort reasons with an Error', async () => {
3155 + function App() {
3156 + return (
3157 + <div>
3158 + <p>
3159 + <Suspense fallback={'p'}>
3160 + <AsyncText text={'hello'} />
3161 + </Suspense>
3162 + </p>
3163 + <span>
3164 + <Suspense fallback={'span'}>
3165 + <AsyncText text={'world'} />
3166 + </Suspense>
3167 + </span>
3168 + </div>
3169 + );
3170 + }
3171 +
3172 + let abort;
3173 + const loggedErrors = [];
3174 + await act(async () => {
3175 + const {
3176 + pipe,
3177 + abort: abortImpl,
3178 + } = ReactDOMFizzServer.renderToPipeableStream(<App />, {
3179 + onError(error) {
3180 + loggedErrors.push(error.message);
3181 + return 'a digest';
3182 + },
3183 + });
3184 + abort = abortImpl;
3185 + pipe(writable);
3186 + });
3187 +
3188 + expect(loggedErrors).toEqual([]);
3189 + expect(getVisibleChildren(container)).toEqual(
3190 + <div>
3191 + <p>p</p>
3192 + <span>span</span>
3193 + </div>,
3194 + );
3195 +
3196 + await act(() => {
3197 + abort(new Error('uh oh'));
3198 + });
3199 +
3200 + expect(loggedErrors).toEqual(['uh oh', 'uh oh']);
3201 +
3202 + const errors = [];
3203 + ReactDOMClient.hydrateRoot(container, <App />, {
3204 + onRecoverableError(error, errorInfo) {
3205 + errors.push({error, errorInfo});
3206 + },
3207 + });
3208 +
3209 + expect(Scheduler).toFlushAndYield([]);
3210 +
3211 + expectErrors(
3212 + errors,
3213 + [
3214 + [
3215 + 'The server did not finish this Suspense boundary: uh oh',
3216 + 'a digest',
3217 + componentStack(['Suspense', 'p', 'div', 'App']),
3218 + ],
3219 + [
3220 + 'The server did not finish this Suspense boundary: uh oh',
3221 + 'a digest',
3222 + componentStack(['Suspense', 'span', 'div', 'App']),
3223 + ],
3224 + ],
3225 + [
3226 + [
3227 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3228 + 'a digest',
3229 + ],
3230 + [
3231 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
3232 + 'a digest',
3233 + ],
3234 + ],
3235 + );
3236 + });
3237 +
3238 describe('error escaping', () => {
3239 //@gate experimental
3240 it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js
+361 -216
@@ -14,13 +14,20 @@ global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStr
14 global.TextEncoder = require('util').TextEncoder;
15
16 let React;
17 +let ReactDOMClient;
18 let ReactDOMFizzServer;
19 let Suspense;
20 +let Scheduler;
21 +let JSDOM;
22 +let document;
23 +let container;
24
25 describe('ReactDOMFizzServer', () => {
26 beforeEach(() => {
27 jest.resetModules();
28 React = require('react');
29 + ReactDOMClient = require('react-dom/client');
30 + Scheduler = require('scheduler');
31 if (__EXPERIMENTAL__) {
32 ReactDOMFizzServer = require('react-dom/server.browser');
33 }
@@ -48,90 +55,137 @@ describe('ReactDOMFizzServer', () => {
55 }
56 }
57
51 - // @gate experimental
52 - it('should call renderToReadableStream', async () => {
53 - const stream = await ReactDOMFizzServer.renderToReadableStream(
54 - <div>hello world</div>,
55 - );
56 - const result = await readResult(stream);
57 - expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
58 - });
58 + describe('renderToReadableStream', () => {
59 + // @gate experimental
60 + it('should call renderToReadableStream', async () => {
61 + const stream = await ReactDOMFizzServer.renderToReadableStream(
62 + <div>hello world</div>,
63 + );
64 + const result = await readResult(stream);
65 + expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
66 + });
67 +
68 + // @gate experimental
69 + it('should emit DOCTYPE at the root of the document', async () => {
70 + const stream = await ReactDOMFizzServer.renderToReadableStream(
71 + <html>
72 + <body>hello world</body>
73 + </html>,
74 + );
75 + const result = await readResult(stream);
76 + expect(result).toMatchInlineSnapshot(
77 + `"<!DOCTYPE html><html><body>hello world</body></html>"`,
78 + );
79 + });
80
60 - // @gate experimental
61 - it('should emit DOCTYPE at the root of the document', async () => {
62 - const stream = await ReactDOMFizzServer.renderToReadableStream(
63 - <html>
64 - <body>hello world</body>
65 - </html>,
66 - );
67 - const result = await readResult(stream);
68 - expect(result).toMatchInlineSnapshot(
69 - `"<!DOCTYPE html><html><body>hello world</body></html>"`,
70 - );
71 - });
81 + // @gate experimental
82 + it('should emit bootstrap script src at the end', async () => {
83 + const stream = await ReactDOMFizzServer.renderToReadableStream(
84 + <div>hello world</div>,
85 + {
86 + bootstrapScriptContent: 'INIT();',
87 + bootstrapScripts: ['init.js'],
88 + bootstrapModules: ['init.mjs'],
89 + },
90 + );
91 + const result = await readResult(stream);
92 + expect(result).toMatchInlineSnapshot(
93 + `"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
94 + );
95 + });
96 +
97 + // @gate experimental
98 + it('emits all HTML as one unit if we wait until the end to start', async () => {
99 + let hasLoaded = false;
100 + let resolve;
101 + const promise = new Promise(r => (resolve = r));
102 + function Wait() {
103 + if (!hasLoaded) {
104 + throw promise;
105 + }
106 + return 'Done';
107 + }
108 + let isComplete = false;
109 + const stream = await ReactDOMFizzServer.renderToReadableStream(
110 + <div>
111 + <Suspense fallback="Loading">
112 + <Wait />
113 + </Suspense>
114 + </div>,
115 + );
116
73 - // @gate experimental
74 - it('should emit bootstrap script src at the end', async () => {
75 - const stream = await ReactDOMFizzServer.renderToReadableStream(
76 - <div>hello world</div>,
77 - {
78 - bootstrapScriptContent: 'INIT();',
79 - bootstrapScripts: ['init.js'],
80 - bootstrapModules: ['init.mjs'],
81 - },
82 - );
83 - const result = await readResult(stream);
84 - expect(result).toMatchInlineSnapshot(
85 - `"<div>hello world</div><script>INIT();</script><script src=\\"init.js\\" async=\\"\\"></script><script type=\\"module\\" src=\\"init.mjs\\" async=\\"\\"></script>"`,
86 - );
87 - });
117 + stream.allReady.then(() => (isComplete = true));
118
89 - // @gate experimental
90 - it('emits all HTML as one unit if we wait until the end to start', async () => {
91 - let hasLoaded = false;
92 - let resolve;
93 - const promise = new Promise(r => (resolve = r));
94 - function Wait() {
95 - if (!hasLoaded) {
96 - throw promise;
97 - }
98 - return 'Done';
99 - }
100 - let isComplete = false;
101 - const stream = await ReactDOMFizzServer.renderToReadableStream(
102 - <div>
103 - <Suspense fallback="Loading">
104 - <Wait />
105 - </Suspense>
106 - </div>,
107 - );
108 -
109 - stream.allReady.then(() => (isComplete = true));
110 -
111 - await jest.runAllTimers();
112 - expect(isComplete).toBe(false);
113 - // Resolve the loading.
114 - hasLoaded = true;
115 - await resolve();
116 -
117 - await jest.runAllTimers();
118 -
119 - expect(isComplete).toBe(true);
120 -
121 - const result = await readResult(stream);
122 - expect(result).toMatchInlineSnapshot(
123 - `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
124 - );
125 - });
119 + await jest.runAllTimers();
120 + expect(isComplete).toBe(false);
121 + // Resolve the loading.
122 + hasLoaded = true;
123 + await resolve();
124
127 - // @gate experimental
128 - it('should reject the promise when an error is thrown at the root', async () => {
129 - const reportedErrors = [];
130 - let caughtError = null;
131 - try {
132 - await ReactDOMFizzServer.renderToReadableStream(
125 + await jest.runAllTimers();
126 +
127 + expect(isComplete).toBe(true);
128 +
129 + const result = await readResult(stream);
130 + expect(result).toMatchInlineSnapshot(
131 + `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
132 + );
133 + });
134 +
135 + // @gate experimental
136 + it('should reject the promise when an error is thrown at the root', async () => {
137 + const reportedErrors = [];
138 + let caughtError = null;
139 + try {
140 + await ReactDOMFizzServer.renderToReadableStream(
141 + <div>
142 + <Throw />
143 + </div>,
144 + {
145 + onError(x) {
146 + reportedErrors.push(x);
147 + },
148 + },
149 + );
150 + } catch (error) {
151 + caughtError = error;
152 + }
153 + expect(caughtError).toBe(theError);
154 + expect(reportedErrors).toEqual([theError]);
155 + });
156 +
157 + // @gate experimental
158 + it('should reject the promise when an error is thrown inside a fallback', async () => {
159 + const reportedErrors = [];
160 + let caughtError = null;
161 + try {
162 + await ReactDOMFizzServer.renderToReadableStream(
163 + <div>
164 + <Suspense fallback={<Throw />}>
165 + <InfiniteSuspend />
166 + </Suspense>
167 + </div>,
168 + {
169 + onError(x) {
170 + reportedErrors.push(x);
171 + },
172 + },
173 + );
174 + } catch (error) {
175 + caughtError = error;
176 + }
177 + expect(caughtError).toBe(theError);
178 + expect(reportedErrors).toEqual([theError]);
179 + });
180 +
181 + // @gate experimental
182 + it('should not error the stream when an error is thrown inside suspense boundary', async () => {
183 + const reportedErrors = [];
184 + const stream = await ReactDOMFizzServer.renderToReadableStream(
185 <div>
134 - <Throw />
186 + <Suspense fallback={<div>Loading</div>}>
187 + <Throw />
188 + </Suspense>
189 </div>,
190 {
191 onError(x) {
@@ -139,172 +193,263 @@ describe('ReactDOMFizzServer', () => {
193 },
194 },
195 );
142 - } catch (error) {
143 - caughtError = error;
144 - }
145 - expect(caughtError).toBe(theError);
146 - expect(reportedErrors).toEqual([theError]);
147 - });
196
149 - // @gate experimental
150 - it('should reject the promise when an error is thrown inside a fallback', async () => {
151 - const reportedErrors = [];
152 - let caughtError = null;
153 - try {
154 - await ReactDOMFizzServer.renderToReadableStream(
197 + const result = await readResult(stream);
198 + expect(result).toContain('Loading');
199 + expect(reportedErrors).toEqual([theError]);
200 + });
201 +
202 + // @gate experimental
203 + it('should be able to complete by aborting even if the promise never resolves', async () => {
204 + const errors = [];
205 + const controller = new AbortController();
206 + const stream = await ReactDOMFizzServer.renderToReadableStream(
207 <div>
156 - <Suspense fallback={<Throw />}>
208 + <Suspense fallback={<div>Loading</div>}>
209 <InfiniteSuspend />
210 </Suspense>
211 </div>,
212 {
213 + signal: controller.signal,
214 onError(x) {
162 - reportedErrors.push(x);
215 + errors.push(x.message);
216 },
217 },
218 );
166 - } catch (error) {
167 - caughtError = error;
168 - }
169 - expect(caughtError).toBe(theError);
170 - expect(reportedErrors).toEqual([theError]);
171 - });
219
173 - // @gate experimental
174 - it('should not error the stream when an error is thrown inside suspense boundary', async () => {
175 - const reportedErrors = [];
176 - const stream = await ReactDOMFizzServer.renderToReadableStream(
177 - <div>
178 - <Suspense fallback={<div>Loading</div>}>
179 - <Throw />
180 - </Suspense>
181 - </div>,
182 - {
183 - onError(x) {
184 - reportedErrors.push(x);
220 + controller.abort();
221 +
222 + const result = await readResult(stream);
223 + expect(result).toContain('Loading');
224 +
225 + expect(errors).toEqual([
226 + 'The render was aborted by the server without a reason.',
227 + ]);
228 + });
229 +
230 + // @gate experimental
231 + it('should not continue rendering after the reader cancels', async () => {
232 + let hasLoaded = false;
233 + let resolve;
234 + let isComplete = false;
235 + let rendered = false;
236 + const promise = new Promise(r => (resolve = r));
237 + function Wait() {
238 + if (!hasLoaded) {
239 + throw promise;
240 + }
241 + rendered = true;
242 + return 'Done';
243 + }
244 + const errors = [];
245 + const stream = await ReactDOMFizzServer.renderToReadableStream(
246 + <div>
247 + <Suspense fallback={<div>Loading</div>}>
248 + <Wait /> />
249 + </Suspense>
250 + </div>,
251 + {
252 + onError(x) {
253 + errors.push(x.message);
254 + },
255 },
186 - },
187 - );
256 + );
257
189 - const result = await readResult(stream);
190 - expect(result).toContain('Loading');
191 - expect(reportedErrors).toEqual([theError]);
192 - });
258 + stream.allReady.then(() => (isComplete = true));
259 +
260 + expect(rendered).toBe(false);
261 + expect(isComplete).toBe(false);
262 +
263 + const reader = stream.getReader();
264 + reader.cancel();
265 +
266 + expect(errors).toEqual([
267 + 'The render was aborted by the server without a reason.',
268 + ]);
269 +
270 + hasLoaded = true;
271 + resolve();
272 +
273 + await jest.runAllTimers();
274 +
275 + expect(rendered).toBe(false);
276 + expect(isComplete).toBe(true);
277 + });
278 +
279 + // @gate experimental
280 + it('should stream large contents that might overlow individual buffers', async () => {
281 + const str492 = `(492) This string is intentionally 492 bytes long because we want to make sure we process chunks that will overflow buffer boundaries. It will repeat to fill out the bytes required (inclusive of this prompt):: foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux q :: total count (492)`;
282 + const str2049 = `(2049) This string is intentionally 2049 bytes long because we want to make sure we process chunks that will overflow buffer boundaries. It will repeat to fill out the bytes required (inclusive of this prompt):: foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy :: total count (2049)`;
283 +
284 + // this specific layout is somewhat contrived to exercise the landing on
285 + // an exact view boundary. it's not critical to test this edge case but
286 + // since we are setting up a test in general for larger chunks I contrived it
287 + // as such for now. I don't think it needs to be maintained if in the future
288 + // the view sizes change or become dynamic becasue of the use of byobRequest
289 + let stream;
290 + stream = await ReactDOMFizzServer.renderToReadableStream(
291 + <>
292 + <div>
293 + <span>{''}</span>
294 + </div>
295 + <div>{str492}</div>
296 + <div>{str492}</div>
297 + </>,
298 + );
299
194 - // @gate experimental
195 - it('should be able to complete by aborting even if the promise never resolves', async () => {
196 - const errors = [];
197 - const controller = new AbortController();
198 - const stream = await ReactDOMFizzServer.renderToReadableStream(
199 - <div>
200 - <Suspense fallback={<div>Loading</div>}>
201 - <InfiniteSuspend />
202 - </Suspense>
203 - </div>,
204 - {
300 + let result;
301 + result = await readResult(stream);
302 + expect(result).toMatchInlineSnapshot(
303 + `"<div><span></span></div><div>${str492}</div><div>${str492}</div>"`,
304 + );
305 +
306 + // this size 2049 was chosen to be a couple base 2 orders larger than the current view
307 + // size. if the size changes in the future hopefully this will still exercise
308 + // a chunk that is too large for the view size.
309 + stream = await ReactDOMFizzServer.renderToReadableStream(
310 + <>
311 + <div>{str2049}</div>
312 + </>,
313 + );
314 +
315 + result = await readResult(stream);
316 + expect(result).toMatchInlineSnapshot(`"<div>${str2049}</div>"`);
317 + });
318 +
319 + // @gate experimental
320 + it('Supports custom abort reasons with a string', async () => {
321 + const promise = new Promise(r => {});
322 + function Wait() {
323 + throw promise;
324 + }
325 + function App() {
326 + return (
327 + <div>
328 + <p>
329 + <Suspense fallback={'p'}>
330 + <Wait />
331 + </Suspense>
332 + </p>
333 + <span>
334 + <Suspense fallback={'span'}>
335 + <Wait />
336 + </Suspense>
337 + </span>
338 + </div>
339 + );
340 + }
341 +
342 + const errors = [];
343 + const controller = new AbortController();
344 + await ReactDOMFizzServer.renderToReadableStream(<App />, {
345 signal: controller.signal,
346 onError(x) {
207 - errors.push(x.message);
347 + errors.push(x);
348 + return 'a digest';
349 },
209 - },
210 - );
350 + });
351
212 - controller.abort();
352 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
353 + // The abort call itself should set this property but since we are testing in node we
354 + // set it here manually
355 + controller.signal.reason = 'foobar';
356 + controller.abort('foobar');
357
214 - const result = await readResult(stream);
215 - expect(result).toContain('Loading');
216 -
217 - expect(errors).toEqual([
218 - 'This Suspense boundary was aborted by the server.',
219 - ]);
220 - });
358 + expect(errors).toEqual(['foobar', 'foobar']);
359 + });
360
222 - // @gate experimental
223 - it('should not continue rendering after the reader cancels', async () => {
224 - let hasLoaded = false;
225 - let resolve;
226 - let isComplete = false;
227 - let rendered = false;
228 - const promise = new Promise(r => (resolve = r));
229 - function Wait() {
230 - if (!hasLoaded) {
361 + // @gate experimental
362 + it('Supports custom abort reasons with an Error', async () => {
363 + const promise = new Promise(r => {});
364 + function Wait() {
365 throw promise;
366 }
233 - rendered = true;
234 - return 'Done';
235 - }
236 - const errors = [];
237 - const stream = await ReactDOMFizzServer.renderToReadableStream(
238 - <div>
239 - <Suspense fallback={<div>Loading</div>}>
240 - <Wait /> />
241 - </Suspense>
242 - </div>,
243 - {
367 + function App() {
368 + return (
369 + <div>
370 + <p>
371 + <Suspense fallback={'p'}>
372 + <Wait />
373 + </Suspense>
374 + </p>
375 + <span>
376 + <Suspense fallback={'span'}>
377 + <Wait />
378 + </Suspense>
379 + </span>
380 + </div>
381 + );
382 + }
383 +
384 + const errors = [];
385 + const controller = new AbortController();
386 + await ReactDOMFizzServer.renderToReadableStream(<App />, {
387 + signal: controller.signal,
388 onError(x) {
389 errors.push(x.message);
390 + return 'a digest';
391 },
247 - },
248 - );
249 -
250 - stream.allReady.then(() => (isComplete = true));
392 + });
393
252 - expect(rendered).toBe(false);
253 - expect(isComplete).toBe(false);
394 + // @TODO this is a hack to work around lack of support for abortSignal.reason in node
395 + // The abort call itself should set this property but since we are testing in node we
396 + // set it here manually
397 + controller.signal.reason = new Error('uh oh');
398 + controller.abort(new Error('uh oh'));
399
255 - const reader = stream.getReader();
256 - reader.cancel();
257 -
258 - expect(errors).toEqual([
259 - 'This Suspense boundary was aborted by the server.',
260 - ]);
261 -
262 - hasLoaded = true;
263 - resolve();
400 + expect(errors).toEqual(['uh oh', 'uh oh']);
401 + });
402 + });
403
265 - await jest.runAllTimers();
404 + describe('renderToString', () => {
405 + beforeEach(() => {
406 + JSDOM = require('jsdom').JSDOM;
407
267 - expect(rendered).toBe(false);
268 - expect(isComplete).toBe(true);
269 - });
408 + // Test Environment
409 + const jsdom = new JSDOM(
410 + '<!DOCTYPE html><html><head></head><body><div id="container">',
411 + {
412 + runScripts: 'dangerously',
413 + },
414 + );
415 + document = jsdom.window.document;
416 + container = document.getElementById('container');
417 + });
418 +
419 + // @gate experimental
420 + it('refers users to apis that support Suspense when somethign suspends', () => {
421 + function App({isClient}) {
422 + return (
423 + <div>
424 + <Suspense fallback={'fallback'}>
425 + {isClient ? 'resolved' : <InfiniteSuspend />}
426 + </Suspense>
427 + </div>
428 + );
429 + }
430 + container.innerHTML = ReactDOMFizzServer.renderToString(
431 + <App isClient={false} />,
432 + );
433
271 - // @gate experimental
272 - it('should stream large contents that might overlow individual buffers', async () => {
273 - const str492 = `(492) This string is intentionally 492 bytes long because we want to make sure we process chunks that will overflow buffer boundaries. It will repeat to fill out the bytes required (inclusive of this prompt):: foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux q :: total count (492)`;
274 - const str2049 = `(2049) This string is intentionally 2049 bytes long because we want to make sure we process chunks that will overflow buffer boundaries. It will repeat to fill out the bytes required (inclusive of this prompt):: foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy thud foo bar qux quux corge grault garply waldo fred plugh xyzzy :: total count (2049)`;
275 -
276 - // this specific layout is somewhat contrived to exercise the landing on
277 - // an exact view boundary. it's not critical to test this edge case but
278 - // since we are setting up a test in general for larger chunks I contrived it
279 - // as such for now. I don't think it needs to be maintained if in the future
280 - // the view sizes change or become dynamic becasue of the use of byobRequest
281 - let stream;
282 - stream = await ReactDOMFizzServer.renderToReadableStream(
283 - <>
284 - <div>
285 - <span>{''}</span>
286 - </div>
287 - <div>{str492}</div>
288 - <div>{str492}</div>
289 - </>,
290 - );
291 -
292 - let result;
293 - result = await readResult(stream);
294 - expect(result).toMatchInlineSnapshot(
295 - `"<div><span></span></div><div>${str492}</div><div>${str492}</div>"`,
296 - );
297 -
298 - // this size 2049 was chosen to be a couple base 2 orders larger than the current view
299 - // size. if the size changes in the future hopefully this will still exercise
300 - // a chunk that is too large for the view size.
301 - stream = await ReactDOMFizzServer.renderToReadableStream(
302 - <>
303 - <div>{str2049}</div>
304 - </>,
305 - );
306 -
307 - result = await readResult(stream);
308 - expect(result).toMatchInlineSnapshot(`"<div>${str2049}</div>"`);
434 + const errors = [];
435 + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
436 + onRecoverableError(error, errorInfo) {
437 + errors.push(error.message);
438 + },
439 + });
440 +
441 + expect(Scheduler).toFlushAndYield([]);
442 + expect(errors.length).toBe(1);
443 + if (__DEV__) {
444 + expect(errors[0]).toBe(
445 + 'The server did not finish this Suspense boundary: The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server',
446 + );
447 + } else {
448 + expect(errors[0]).toBe(
449 + 'The server could not finish this Suspense boundary, likely due to ' +
450 + 'an error during server rendering. Switched to client rendering.',
451 + );
452 + }
453 + });
454 });
455 });
packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js
+6 -8
@@ -226,7 +226,7 @@ describe('ReactDOMFizzServer', () => {
226 expect(output.result).toBe('');
227 expect(reportedErrors).toEqual([
228 theError.message,
229 - 'This Suspense boundary was aborted by the server.',
229 + 'The destination stream errored while writing data.',
230 ]);
231 expect(reportedShellErrors).toEqual([theError]);
232 });
@@ -317,13 +317,11 @@ describe('ReactDOMFizzServer', () => {
317 expect(output.result).toContain('Loading');
318 expect(isCompleteCalls).toBe(0);
319
320 - abort();
320 + abort(new Error('uh oh'));
321
322 await completed;
323
324 - expect(errors).toEqual([
325 - 'This Suspense boundary was aborted by the server.',
326 - ]);
324 + expect(errors).toEqual(['uh oh']);
325 expect(output.error).toBe(undefined);
326 expect(output.result).toContain('Loading');
327 expect(isCompleteCalls).toBe(1);
@@ -365,8 +363,8 @@ describe('ReactDOMFizzServer', () => {
363
364 expect(errors).toEqual([
365 // There are two boundaries that abort
368 - 'This Suspense boundary was aborted by the server.',
369 - 'This Suspense boundary was aborted by the server.',
366 + 'The render was aborted by the server without a reason.',
367 + 'The render was aborted by the server without a reason.',
368 ]);
369 expect(output.error).toBe(undefined);
370 expect(output.result).toContain('Loading');
@@ -603,7 +601,7 @@ describe('ReactDOMFizzServer', () => {
601 await completed;
602
603 expect(errors).toEqual([
606 - 'This Suspense boundary was aborted by the server.',
604 + 'The destination stream errored while writing data.',
605 ]);
606 expect(rendered).toBe(false);
607 expect(isComplete).toBe(true);
packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
+2 -2
@@ -830,7 +830,7 @@ describe('ReactDOMServerHydration', () => {
830 } else {
831 expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
832 Array [
833 - "Caught [This Suspense boundary was aborted by the server.]",
833 + "Caught [The server did not finish this Suspense boundary: The server used \\"renderToString\\" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to \\"renderToPipeableStream\\" which supports Suspense on the server]",
834 ]
835 `);
836 }
@@ -865,7 +865,7 @@ describe('ReactDOMServerHydration', () => {
865 } else {
866 expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
867 Array [
868 - "Caught [This Suspense boundary was aborted by the server.]",
868 + "Caught [The server did not finish this Suspense boundary: The server used \\"renderToString\\" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to \\"renderToPipeableStream\\" which supports Suspense on the server]",
869 ]
870 `);
871 }
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+40 -10
@@ -1674,11 +1674,17 @@ describe('ReactDOMServerPartialHydration', () => {
1674 // we exclude fb bundles with partial renderer
1675 if (__DEV__ && !usingPartialRenderer) {
1676 expect(Scheduler).toFlushAndYield([
1677 - 'This Suspense boundary was aborted by the server.',
1677 + 'The server did not finish this Suspense boundary: The server used' +
1678 + ' "renderToString" which does not support Suspense. If you intended' +
1679 + ' for this Suspense boundary to render the fallback content on the' +
1680 + ' server consider throwing an Error somewhere within the Suspense boundary.' +
1681 + ' If you intended to have the server wait for the suspended component' +
1682 + ' please switch to "renderToPipeableStream" which supports Suspense on the server',
1683 ]);
1684 } else {
1685 expect(Scheduler).toFlushAndYield([
1681 - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1686 + 'The server could not finish this Suspense boundary, likely due to ' +
1687 + 'an error during server rendering. Switched to client rendering.',
1688 ]);
1689 }
1690 jest.runAllTimers();
@@ -1742,11 +1748,17 @@ describe('ReactDOMServerPartialHydration', () => {
1748 // we exclude fb bundles with partial renderer
1749 if (__DEV__ && !usingPartialRenderer) {
1750 expect(Scheduler).toFlushAndYield([
1745 - 'This Suspense boundary was aborted by the server.',
1751 + 'The server did not finish this Suspense boundary: The server used' +
1752 + ' "renderToString" which does not support Suspense. If you intended' +
1753 + ' for this Suspense boundary to render the fallback content on the' +
1754 + ' server consider throwing an Error somewhere within the Suspense boundary.' +
1755 + ' If you intended to have the server wait for the suspended component' +
1756 + ' please switch to "renderToPipeableStream" which supports Suspense on the server',
1757 ]);
1758 } else {
1759 expect(Scheduler).toFlushAndYield([
1749 - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1760 + 'The server could not finish this Suspense boundary, likely due to ' +
1761 + 'an error during server rendering. Switched to client rendering.',
1762 ]);
1763 }
1764 // This will have exceeded the suspended time so we should timeout.
@@ -1815,11 +1827,17 @@ describe('ReactDOMServerPartialHydration', () => {
1827 // we exclude fb bundles with partial renderer
1828 if (__DEV__ && !usingPartialRenderer) {
1829 expect(Scheduler).toFlushAndYield([
1818 - 'This Suspense boundary was aborted by the server.',
1830 + 'The server did not finish this Suspense boundary: The server used' +
1831 + ' "renderToString" which does not support Suspense. If you intended' +
1832 + ' for this Suspense boundary to render the fallback content on the' +
1833 + ' server consider throwing an Error somewhere within the Suspense boundary.' +
1834 + ' If you intended to have the server wait for the suspended component' +
1835 + ' please switch to "renderToPipeableStream" which supports Suspense on the server',
1836 ]);
1837 } else {
1838 expect(Scheduler).toFlushAndYield([
1822 - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
1839 + 'The server could not finish this Suspense boundary, likely due to ' +
1840 + 'an error during server rendering. Switched to client rendering.',
1841 ]);
1842 }
1843 // This will have exceeded the suspended time so we should timeout.
@@ -2139,11 +2157,17 @@ describe('ReactDOMServerPartialHydration', () => {
2157 // we exclude fb bundles with partial renderer
2158 if (__DEV__ && !usingPartialRenderer) {
2159 expect(Scheduler).toFlushAndYield([
2142 - 'This Suspense boundary was aborted by the server.',
2160 + 'The server did not finish this Suspense boundary: The server used' +
2161 + ' "renderToString" which does not support Suspense. If you intended' +
2162 + ' for this Suspense boundary to render the fallback content on the' +
2163 + ' server consider throwing an Error somewhere within the Suspense boundary.' +
2164 + ' If you intended to have the server wait for the suspended component' +
2165 + ' please switch to "renderToPipeableStream" which supports Suspense on the server',
2166 ]);
2167 } else {
2168 expect(Scheduler).toFlushAndYield([
2146 - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
2169 + 'The server could not finish this Suspense boundary, likely due to ' +
2170 + 'an error during server rendering. Switched to client rendering.',
2171 ]);
2172 }
2173
@@ -2208,11 +2232,17 @@ describe('ReactDOMServerPartialHydration', () => {
2232 // we exclude fb bundles with partial renderer
2233 if (__DEV__ && !usingPartialRenderer) {
2234 expect(Scheduler).toFlushAndYield([
2211 - 'This Suspense boundary was aborted by the server.',
2235 + 'The server did not finish this Suspense boundary: The server used' +
2236 + ' "renderToString" which does not support Suspense. If you intended' +
2237 + ' for this Suspense boundary to render the fallback content on the' +
2238 + ' server consider throwing an Error somewhere within the Suspense boundary.' +
2239 + ' If you intended to have the server wait for the suspended component' +
2240 + ' please switch to "renderToPipeableStream" which supports Suspense on the server',
2241 ]);
2242 } else {
2243 expect(Scheduler).toFlushAndYield([
2215 - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
2244 + 'The server could not finish this Suspense boundary, likely due to ' +
2245 + 'an error during server rendering. Switched to client rendering.',
2246 ]);
2247 }
2248 jest.runAllTimers();
packages/react-dom/src/__tests__/ReactServerRendering-test.js
+55
@@ -14,6 +14,7 @@ let React;
14 let ReactDOMServer;
15 let PropTypes;
16 let ReactCurrentDispatcher;
17 +let useingPartialRenderer;
18
19 describe('ReactDOMServer', () => {
20 beforeEach(() => {
@@ -24,6 +25,8 @@ describe('ReactDOMServer', () => {
25 ReactCurrentDispatcher =
26 React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
27 .ReactCurrentDispatcher;
28 +
29 + useingPartialRenderer = global.__WWW__ && !__EXPERIMENTAL__;
30 });
31
32 describe('renderToString', () => {
@@ -562,6 +565,23 @@ describe('ReactDOMServer', () => {
565 'Bad lazy',
566 );
567 });
568 +
569 + it('aborts synchronously any suspended tasks and renders their fallbacks', () => {
570 + const promise = new Promise(res => {});
571 + function Suspender() {
572 + throw promise;
573 + }
574 + const response = ReactDOMServer.renderToStaticMarkup(
575 + <React.Suspense fallback={'fallback'}>
576 + <Suspender />
577 + </React.Suspense>,
578 + );
579 + if (useingPartialRenderer) {
580 + expect(response).toEqual('<!--$!-->fallback<!--/$-->');
581 + } else {
582 + expect(response).toEqual('fallback');
583 + }
584 + });
585 });
586
587 describe('renderToNodeStream', () => {
@@ -618,6 +638,41 @@ describe('ReactDOMServer', () => {
638 expect(response.read()).toBeNull();
639 });
640 });
641 +
642 + it('should refer users to new apis when using suspense', async () => {
643 + let resolve = null;
644 + const promise = new Promise(res => {
645 + resolve = () => {
646 + resolved = true;
647 + res();
648 + };
649 + });
650 + let resolved = false;
651 + function Suspender() {
652 + if (resolved) {
653 + return 'resolved';
654 + }
655 + throw promise;
656 + }
657 +
658 + let response;
659 + expect(() => {
660 + response = ReactDOMServer.renderToNodeStream(
661 + <div>
662 + <React.Suspense fallback={'fallback'}>
663 + <Suspender />
664 + </React.Suspense>
665 + </div>,
666 + );
667 + }).toErrorDev(
668 + 'renderToNodeStream is deprecated. Use renderToPipeableStream instead.',
669 + {withoutStack: true},
670 + );
671 + await resolve();
672 + expect(response.read().toString()).toEqual(
673 + '<div><!--$-->resolved<!-- --><!--/$--></div>',
674 + );
675 + });
676 });
677
678 it('warns with a no-op when an async setState is triggered', () => {
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+1 -1
@@ -97,7 +97,7 @@ function renderToReadableStream(
97 if (options && options.signal) {
98 const signal = options.signal;
99 const listener = () => {
100 - abort(request);
100 + abort(request, (signal: any).reason);
101 signal.removeEventListener('abort', listener);
102 };
103 signal.addEventListener('abort', listener);
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+20 -5
@@ -28,8 +28,8 @@ function createDrainHandler(destination, request) {
28 return () => startFlowing(request, destination);
29 }
30
31 -function createAbortHandler(request) {
32 - return () => abort(request);
31 +function createAbortHandler(request, reason) {
32 + return () => abort(request, reason);
33 }
34
35 type Options = {|
@@ -90,11 +90,26 @@ function renderToPipeableStream(
90 hasStartedFlowing = true;
91 startFlowing(request, destination);
92 destination.on('drain', createDrainHandler(destination, request));
93 - destination.on('close', createAbortHandler(request));
93 + destination.on(
94 + 'error',
95 + createAbortHandler(
96 + request,
97 + // eslint-disable-next-line react-internal/prod-error-codes
98 + new Error('The destination stream errored while writing data.'),
99 + ),
100 + );
101 + destination.on(
102 + 'close',
103 + createAbortHandler(
104 + request,
105 + // eslint-disable-next-line react-internal/prod-error-codes
106 + new Error('The destination stream closed early.'),
107 + ),
108 + );
109 return destination;
110 },
96 - abort() {
97 - abort(request);
111 + abort(reason) {
112 + abort(request, reason);
113 },
114 };
115 }
packages/react-dom/src/server/ReactDOMLegacyServerBrowser.js
+14 -82
@@ -7,104 +7,36 @@
7 * @flow
8 */
9
10 -import ReactVersion from 'shared/ReactVersion';
11 -
10 import type {ReactNodeList} from 'shared/ReactTypes';
11
14 -import {
15 - createRequest,
16 - startWork,
17 - startFlowing,
18 - abort,
19 -} from 'react-server/src/ReactFizzServer';
20 -
21 -import {
22 - createResponseState,
23 - createRootFormatContext,
24 -} from './ReactDOMServerLegacyFormatConfig';
12 +import {version, renderToStringImpl} from './ReactDOMLegacyServerImpl';
13
14 type ServerOptions = {
15 identifierPrefix?: string,
16 };
17
30 -function onError() {
31 - // Non-fatal errors are ignored.
32 -}
33 -
34 -function renderToStringImpl(
35 - children: ReactNodeList,
36 - options: void | ServerOptions,
37 - generateStaticMarkup: boolean,
38 -): string {
39 - let didFatal = false;
40 - let fatalError = null;
41 - let result = '';
42 - const destination = {
43 - push(chunk) {
44 - if (chunk !== null) {
45 - result += chunk;
46 - }
47 - return true;
48 - },
49 - destroy(error) {
50 - didFatal = true;
51 - fatalError = error;
52 - },
53 - };
54 -
55 - let readyToStream = false;
56 - function onShellReady() {
57 - readyToStream = true;
58 - }
59 - const request = createRequest(
60 - children,
61 - createResponseState(
62 - generateStaticMarkup,
63 - options ? options.identifierPrefix : undefined,
64 - ),
65 - createRootFormatContext(),
66 - Infinity,
67 - onError,
68 - undefined,
69 - onShellReady,
70 - undefined,
71 - undefined,
72 - );
73 - startWork(request);
74 - // If anything suspended and is still pending, we'll abort it before writing.
75 - // That way we write only client-rendered boundaries from the start.
76 - abort(request);
77 - startFlowing(request, destination);
78 - if (didFatal) {
79 - throw fatalError;
80 - }
81 -
82 - if (!readyToStream) {
83 - // Note: This error message is the one we use on the client. It doesn't
84 - // really make sense here. But this is the legacy server renderer, anyway.
85 - // We're going to delete it soon.
86 - throw new Error(
87 - 'A component suspended while responding to synchronous input. This ' +
88 - 'will cause the UI to be replaced with a loading indicator. To fix, ' +
89 - 'updates that suspend should be wrapped with startTransition.',
90 - );
91 - }
92 -
93 - return result;
94 -}
95 -
18 function renderToString(
19 children: ReactNodeList,
20 options?: ServerOptions,
21 ): string {
100 - return renderToStringImpl(children, options, false);
22 + return renderToStringImpl(
23 + children,
24 + options,
25 + false,
26 + 'The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server',
27 + );
28 }
29
30 function renderToStaticMarkup(
31 children: ReactNodeList,
32 options?: ServerOptions,
33 ): string {
107 - return renderToStringImpl(children, options, true);
34 + return renderToStringImpl(
35 + children,
36 + options,
37 + true,
38 + 'The server used "renderToStaticMarkup" which does not support Suspense. If you intended to have the server wait for the suspended component please switch to "renderToReadableStream" which supports Suspense on the server',
39 + );
40 }
41
42 function renderToNodeStream() {
@@ -126,5 +58,5 @@ export {
58 renderToStaticMarkup,
59 renderToNodeStream,
60 renderToStaticNodeStream,
129 - ReactVersion as version,
61 + version,
62 };
packages/react-dom/src/server/ReactDOMLegacyServerImpl.js new
+97
@@ -0,0 +1,97 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import ReactVersion from 'shared/ReactVersion';
11 +
12 +import type {ReactNodeList} from 'shared/ReactTypes';
13 +
14 +import {
15 + createRequest,
16 + startWork,
17 + startFlowing,
18 + abort,
19 +} from 'react-server/src/ReactFizzServer';
20 +
21 +import {
22 + createResponseState,
23 + createRootFormatContext,
24 +} from './ReactDOMServerLegacyFormatConfig';
25 +
26 +type ServerOptions = {
27 + identifierPrefix?: string,
28 +};
29 +
30 +function onError() {
31 + // Non-fatal errors are ignored.
32 +}
33 +
34 +function renderToStringImpl(
35 + children: ReactNodeList,
36 + options: void | ServerOptions,
37 + generateStaticMarkup: boolean,
38 + abortReason: string,
39 +): string {
40 + let didFatal = false;
41 + let fatalError = null;
42 + let result = '';
43 + const destination = {
44 + push(chunk) {
45 + if (chunk !== null) {
46 + result += chunk;
47 + }
48 + return true;
49 + },
50 + destroy(error) {
51 + didFatal = true;
52 + fatalError = error;
53 + },
54 + };
55 +
56 + let readyToStream = false;
57 + function onShellReady() {
58 + readyToStream = true;
59 + }
60 + const request = createRequest(
61 + children,
62 + createResponseState(
63 + generateStaticMarkup,
64 + options ? options.identifierPrefix : undefined,
65 + ),
66 + createRootFormatContext(),
67 + Infinity,
68 + onError,
69 + undefined,
70 + onShellReady,
71 + undefined,
72 + undefined,
73 + );
74 + startWork(request);
75 + // If anything suspended and is still pending, we'll abort it before writing.
76 + // That way we write only client-rendered boundaries from the start.
77 + abort(request, abortReason);
78 + startFlowing(request, destination);
79 + if (didFatal) {
80 + throw fatalError;
81 + }
82 +
83 + if (!readyToStream) {
84 + // Note: This error message is the one we use on the client. It doesn't
85 + // really make sense here. But this is the legacy server renderer, anyway.
86 + // We're going to delete it soon.
87 + throw new Error(
88 + 'A component suspended while responding to synchronous input. This ' +
89 + 'will cause the UI to be replaced with a loading indicator. To fix, ' +
90 + 'updates that suspend should be wrapped with startTransition.',
91 + );
92 + }
93 +
94 + return result;
95 +}
96 +
97 +export {renderToStringImpl, ReactVersion as version};
packages/react-dom/src/server/ReactDOMLegacyServerNode.classic.fb.js new
+19
@@ -0,0 +1,19 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export {
11 + renderToString,
12 + renderToStaticMarkup,
13 + version,
14 +} from './ReactDOMServerLegacyPartialRendererBrowser';
15 +
16 +export {
17 + renderToNodeStream,
18 + renderToStaticNodeStream,
19 +} from './ReactDOMLegacyServerNodeStream';
packages/react-dom/src/server/ReactDOMLegacyServerNode.js
+19 -85
@@ -9,104 +9,38 @@
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11
12 -import type {Request} from 'react-server/src/ReactFizzServer';
13 -
14 -import {
15 - createRequest,
16 - startWork,
17 - startFlowing,
18 - abort,
19 -} from 'react-server/src/ReactFizzServer';
20 -
12 +import {version, renderToStringImpl} from './ReactDOMLegacyServerImpl';
13 import {
22 - createResponseState,
23 - createRootFormatContext,
24 -} from './ReactDOMServerLegacyFormatConfig';
25 -
26 -import {
27 - version,
28 - renderToString,
29 - renderToStaticMarkup,
30 -} from './ReactDOMLegacyServerBrowser';
31 -
32 -import {Readable} from 'stream';
14 + renderToNodeStream,
15 + renderToStaticNodeStream,
16 +} from './ReactDOMLegacyServerNodeStream';
17
18 type ServerOptions = {
19 identifierPrefix?: string,
20 };
21
38 -class ReactMarkupReadableStream extends Readable {
39 - request: Request;
40 - startedFlowing: boolean;
41 - constructor() {
42 - // Calls the stream.Readable(options) constructor. Consider exposing built-in
43 - // features like highWaterMark in the future.
44 - super({});
45 - this.request = (null: any);
46 - this.startedFlowing = false;
47 - }
48 -
49 - _destroy(err, callback) {
50 - abort(this.request);
51 - // $FlowFixMe: The type definition for the callback should allow undefined and null.
52 - callback(err);
53 - }
54 -
55 - _read(size) {
56 - if (this.startedFlowing) {
57 - startFlowing(this.request, this);
58 - }
59 - }
60 -}
61 -
62 -function onError() {
63 - // Non-fatal errors are ignored.
64 -}
65 -
66 -function renderToNodeStreamImpl(
22 +function renderToString(
23 children: ReactNodeList,
68 - options: void | ServerOptions,
69 - generateStaticMarkup: boolean,
70 -): Readable {
71 - function onAllReady() {
72 - // We wait until everything has loaded before starting to write.
73 - // That way we only end up with fully resolved HTML even if we suspend.
74 - destination.startedFlowing = true;
75 - startFlowing(request, destination);
76 - }
77 - const destination = new ReactMarkupReadableStream();
78 - const request = createRequest(
24 + options?: ServerOptions,
25 +): string {
26 + return renderToStringImpl(
27 children,
80 - createResponseState(false, options ? options.identifierPrefix : undefined),
81 - createRootFormatContext(),
82 - Infinity,
83 - onError,
84 - onAllReady,
85 - undefined,
86 - undefined,
28 + options,
29 + false,
30 + 'The server used "renderToString" which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to "renderToPipeableStream" which supports Suspense on the server',
31 );
88 - destination.request = request;
89 - startWork(request);
90 - return destination;
32 }
33
93 -function renderToNodeStream(
34 +function renderToStaticMarkup(
35 children: ReactNodeList,
36 options?: ServerOptions,
96 -): Readable {
97 - if (__DEV__) {
98 - console.error(
99 - 'renderToNodeStream is deprecated. Use renderToPipeableStream instead.',
100 - );
101 - }
102 - return renderToNodeStreamImpl(children, options, false);
103 -}
104 -
105 -function renderToStaticNodeStream(
106 - children: ReactNodeList,
107 - options?: ServerOptions,
108 -): Readable {
109 - return renderToNodeStreamImpl(children, options, true);
37 +): string {
38 + return renderToStringImpl(
39 + children,
40 + options,
41 + true,
42 + 'The server used "renderToStaticMarkup" which does not support Suspense. If you intended to have the server wait for the suspended component please switch to "renderToPipeableStream" which supports Suspense on the server',
43 + );
44 }
45
46 export {
packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js new
+106
@@ -0,0 +1,106 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {ReactNodeList} from 'shared/ReactTypes';
11 +
12 +import type {Request} from 'react-server/src/ReactFizzServer';
13 +
14 +import {
15 + createRequest,
16 + startWork,
17 + startFlowing,
18 + abort,
19 +} from 'react-server/src/ReactFizzServer';
20 +
21 +import {
22 + createResponseState,
23 + createRootFormatContext,
24 +} from './ReactDOMServerLegacyFormatConfig';
25 +
26 +import {Readable} from 'stream';
27 +
28 +type ServerOptions = {
29 + identifierPrefix?: string,
30 +};
31 +
32 +class ReactMarkupReadableStream extends Readable {
33 + request: Request;
34 + startedFlowing: boolean;
35 + constructor() {
36 + // Calls the stream.Readable(options) constructor. Consider exposing built-in
37 + // features like highWaterMark in the future.
38 + super({});
39 + this.request = (null: any);
40 + this.startedFlowing = false;
41 + }
42 +
43 + _destroy(err, callback) {
44 + abort(this.request);
45 + // $FlowFixMe: The type definition for the callback should allow undefined and null.
46 + callback(err);
47 + }
48 +
49 + _read(size) {
50 + if (this.startedFlowing) {
51 + startFlowing(this.request, this);
52 + }
53 + }
54 +}
55 +
56 +function onError() {
57 + // Non-fatal errors are ignored.
58 +}
59 +
60 +function renderToNodeStreamImpl(
61 + children: ReactNodeList,
62 + options: void | ServerOptions,
63 + generateStaticMarkup: boolean,
64 +): Readable {
65 + function onAllReady() {
66 + // We wait until everything has loaded before starting to write.
67 + // That way we only end up with fully resolved HTML even if we suspend.
68 + destination.startedFlowing = true;
69 + startFlowing(request, destination);
70 + }
71 + const destination = new ReactMarkupReadableStream();
72 + const request = createRequest(
73 + children,
74 + createResponseState(false, options ? options.identifierPrefix : undefined),
75 + createRootFormatContext(),
76 + Infinity,
77 + onError,
78 + onAllReady,
79 + undefined,
80 + undefined,
81 + );
82 + destination.request = request;
83 + startWork(request);
84 + return destination;
85 +}
86 +
87 +function renderToNodeStream(
88 + children: ReactNodeList,
89 + options?: ServerOptions,
90 +): Readable {
91 + if (__DEV__) {
92 + console.error(
93 + 'renderToNodeStream is deprecated. Use renderToPipeableStream instead.',
94 + );
95 + }
96 + return renderToNodeStreamImpl(children, options, false);
97 +}
98 +
99 +function renderToStaticNodeStream(
100 + children: ReactNodeList,
101 + options?: ServerOptions,
102 +): Readable {
103 + return renderToNodeStreamImpl(children, options, true);
104 +}
105 +
106 +export {renderToNodeStream, renderToStaticNodeStream};
packages/react-server-dom-relay/src/__tests__/ReactDOMServerFB-test.internal.js
+1 -1
@@ -192,7 +192,7 @@ describe('ReactDOMServerFB', () => {
192 expect(remaining).toEqual('');
193
194 expect(errors).toEqual([
195 - 'This Suspense boundary was aborted by the server.',
195 + 'The render was aborted by the server without a reason.',
196 ]);
197 });
198 });
packages/react-server/src/ReactFizzServer.js
+25 -9
@@ -1530,10 +1530,9 @@ function abortTaskSoft(task: Task): void {
1530 finishedTask(request, boundary, segment);
1531 }
1532
1533 -function abortTask(task: Task): void {
1533 +function abortTask(task: Task, request: Request, reason: mixed): void {
1534 // This aborts the task and aborts the parent that it blocks, putting it into
1535 // client rendered mode.
1536 - const request: Request = this;
1536 const boundary = task.blockedBoundary;
1537 const segment = task.blockedSegment;
1538 segment.status = ABORTED;
@@ -1553,12 +1552,27 @@ function abortTask(task: Task): void {
1552
1553 if (!boundary.forceClientRender) {
1554 boundary.forceClientRender = true;
1556 - const error = new Error(
1557 - 'This Suspense boundary was aborted by the server.',
1558 - );
1555 + let error =
1556 + reason === undefined
1557 + ? new Error('The render was aborted by the server without a reason.')
1558 + : reason;
1559 boundary.errorDigest = request.onError(error);
1560 if (__DEV__) {
1561 - captureBoundaryErrorDetailsDev(boundary, error);
1561 + const errorPrefix =
1562 + 'The server did not finish this Suspense boundary: ';
1563 + if (error && typeof error.message === 'string') {
1564 + error = errorPrefix + error.message;
1565 + } else {
1566 + // eslint-disable-next-line react-internal/safe-string-coercion
1567 + error = errorPrefix + String(error);
1568 + }
1569 + const previousTaskInDev = currentTaskInDEV;
1570 + currentTaskInDEV = task;
1571 + try {
1572 + captureBoundaryErrorDetailsDev(boundary, error);
1573 + } finally {
1574 + currentTaskInDEV = previousTaskInDev;
1575 + }
1576 }
1577 if (boundary.parentFlushed) {
1578 request.clientRenderedBoundaries.push(boundary);
@@ -1567,7 +1581,9 @@ function abortTask(task: Task): void {
1581
1582 // If this boundary was still pending then we haven't already cancelled its fallbacks.
1583 // We'll need to abort the fallbacks, which will also error that parent boundary.
1570 - boundary.fallbackAbortableTasks.forEach(abortTask, request);
1584 + boundary.fallbackAbortableTasks.forEach(fallbackTask =>
1585 + abortTask(fallbackTask, request, reason),
1586 + );
1587 boundary.fallbackAbortableTasks.clear();
1588
1589 request.allPendingTasks--;
@@ -2159,10 +2175,10 @@ export function startFlowing(request: Request, destination: Destination): void {
2175 }
2176
2177 // This is called to early terminate a request. It puts all pending boundaries in client rendered state.
2162 -export function abort(request: Request): void {
2178 +export function abort(request: Request, reason: mixed): void {
2179 try {
2180 const abortableTasks = request.abortableTasks;
2165 - abortableTasks.forEach(abortTask, request);
2181 + abortableTasks.forEach(task => abortTask(task, request, reason));
2182 abortableTasks.clear();
2183 if (request.destination !== null) {
2184 flushCompletedQueues(request, request.destination);
scripts/error-codes/codes.json
+2 -2
@@ -417,7 +417,7 @@
417 "429": "ServerContext: %s already defined",
418 "430": "ServerContext can only have a value prop and children. Found: %s",
419 "431": "React elements are not allowed in ServerContext",
420 - "432": "This Suspense boundary was aborted by the server.",
420 + "432": "The render was aborted by the server without a reason.",
421 "433": "useId can only be used while React is rendering",
422 "434": "`dangerouslySetInnerHTML` does not make sense on <title>."
423 -}
423 +}
\ No newline at end of file
scripts/shared/inlinedHostConfigs.js
+3
@@ -69,8 +69,11 @@ module.exports = [
69 paths: [
70 'react-dom',
71 'react-server-dom-webpack',
72 + 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Brower and *Node files
73 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser
74 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node
75 + 'react-dom/src/server/ReactDOMLegacyServerNode.classic.fb.js',
76 + 'react-dom/src/server/ReactDOMLegacyServerNodeStream.js', // file indirection to support partial forking of some methods in *Node
77 'react-client/src/ReactFlightClientStream.js', // We can only type check this in streaming configurations.
78 ],
79 isFlowTyped: true,