main
js 440 lines 12.2 KB
Raw
1 let React;
2 let ReactNoop;
3 let Scheduler;
4 let act;
5 let Suspense;
6 let getCacheForType;
7 let startTransition;
8 let assertLog;
9
10 let caches;
11 let seededCache;
12
13 describe('ReactConcurrentErrorRecovery', () => {
14 beforeEach(() => {
15 jest.resetModules();
16
17 React = require('react');
18 ReactNoop = require('react-noop-renderer');
19 Scheduler = require('scheduler');
20 act = require('internal-test-utils').act;
21 Suspense = React.Suspense;
22 startTransition = React.startTransition;
23
24 const InternalTestUtils = require('internal-test-utils');
25 assertLog = InternalTestUtils.assertLog;
26
27 getCacheForType = React.unstable_getCacheForType;
28
29 caches = [];
30 seededCache = null;
31 });
32
33 function createTextCache() {
34 if (seededCache !== null) {
35 // Trick to seed a cache before it exists.
36 // TODO: Need a built-in API to seed data before the initial render (i.e.
37 // not a refresh because nothing has mounted yet).
38 const cache = seededCache;
39 seededCache = null;
40 return cache;
41 }
42
43 const data = new Map();
44 const version = caches.length + 1;
45 const cache = {
46 version,
47 data,
48 resolve(text) {
49 const record = data.get(text);
50 if (record === undefined) {
51 const newRecord = {
52 status: 'resolved',
53 value: text,
54 };
55 data.set(text, newRecord);
56 } else if (record.status === 'pending') {
57 const thenable = record.value;
58 record.status = 'resolved';
59 record.value = text;
60 thenable.pings.forEach(t => t());
61 }
62 },
63 reject(text, error) {
64 const record = data.get(text);
65 if (record === undefined) {
66 const newRecord = {
67 status: 'rejected',
68 value: error,
69 };
70 data.set(text, newRecord);
71 } else if (record.status === 'pending') {
72 const thenable = record.value;
73 record.status = 'rejected';
74 record.value = error;
75 thenable.pings.forEach(t => t());
76 }
77 },
78 };
79 caches.push(cache);
80 return cache;
81 }
82
83 function readText(text) {
84 const textCache = getCacheForType(createTextCache);
85 const record = textCache.data.get(text);
86 if (record !== undefined) {
87 switch (record.status) {
88 case 'pending':
89 Scheduler.log(`Suspend! [${text}]`);
90 throw record.value;
91 case 'rejected':
92 Scheduler.log(`Error! [${text}]`);
93 throw record.value;
94 case 'resolved':
95 return textCache.version;
96 }
97 } else {
98 Scheduler.log(`Suspend! [${text}]`);
99
100 const thenable = {
101 pings: [],
102 then(resolve) {
103 if (newRecord.status === 'pending') {
104 thenable.pings.push(resolve);
105 } else {
106 Promise.resolve().then(() => resolve(newRecord.value));
107 }
108 },
109 };
110
111 const newRecord = {
112 status: 'pending',
113 value: thenable,
114 };
115 textCache.data.set(text, newRecord);
116
117 throw thenable;
118 }
119 }
120
121 function Text({text}) {
122 Scheduler.log(text);
123 return text;
124 }
125
126 function AsyncText({text, showVersion}) {
127 const version = readText(text);
128 const fullText = showVersion ? `${text} [v${version}]` : text;
129 Scheduler.log(fullText);
130 return fullText;
131 }
132
133 function seedNextTextCache(text) {
134 if (seededCache === null) {
135 seededCache = createTextCache();
136 }
137 seededCache.resolve(text);
138 }
139
140 function resolveMostRecentTextCache(text) {
141 if (caches.length === 0) {
142 throw Error('Cache does not exist.');
143 } else {
144 // Resolve the most recently created cache. An older cache can by
145 // resolved with `caches[index].resolve(text)`.
146 caches[caches.length - 1].resolve(text);
147 }
148 }
149
150 const resolveText = resolveMostRecentTextCache;
151
152 function rejectMostRecentTextCache(text, error) {
153 if (caches.length === 0) {
154 throw Error('Cache does not exist.');
155 } else {
156 // Resolve the most recently created cache. An older cache can by
157 // resolved with `caches[index].reject(text, error)`.
158 caches[caches.length - 1].reject(text, error);
159 }
160 }
161
162 const rejectText = rejectMostRecentTextCache;
163
164 // @gate enableLegacyCache
165 it('errors during a refresh transition should not force fallbacks to display (suspend then error)', async () => {
166 class ErrorBoundary extends React.Component {
167 state = {error: null};
168 static getDerivedStateFromError(error) {
169 return {error};
170 }
171 render() {
172 if (this.state.error !== null) {
173 return <Text text={this.state.error.message} />;
174 }
175 return this.props.children;
176 }
177 }
178
179 function App({step}) {
180 return (
181 <>
182 <Suspense fallback={<Text text="Loading..." />}>
183 <ErrorBoundary>
184 <AsyncText text={'A' + step} />
185 </ErrorBoundary>
186 </Suspense>
187 <Suspense fallback={<Text text="Loading..." />}>
188 <ErrorBoundary>
189 <AsyncText text={'B' + step} />
190 </ErrorBoundary>
191 </Suspense>
192 </>
193 );
194 }
195
196 // Initial render
197 const root = ReactNoop.createRoot();
198 seedNextTextCache('A1');
199 seedNextTextCache('B1');
200 await act(() => {
201 root.render(<App step={1} />);
202 });
203 assertLog(['A1', 'B1']);
204 expect(root).toMatchRenderedOutput('A1B1');
205
206 // Start a refresh transition
207 await act(() => {
208 startTransition(() => {
209 root.render(<App step={2} />);
210 });
211 });
212 assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']);
213 // Because this is a refresh, we don't switch to a fallback
214 expect(root).toMatchRenderedOutput('A1B1');
215
216 // B fails to load.
217 await act(() => {
218 rejectText('B2', new Error('Oops!'));
219 });
220
221 // Because we're still suspended on A, we can't show an error boundary. We
222 // should wait for A to resolve.
223 assertLog(['Suspend! [A2]', 'Loading...', 'Error! [B2]', 'Oops!']);
224 // Remain on previous screen.
225 expect(root).toMatchRenderedOutput('A1B1');
226
227 // A finishes loading.
228 await act(() => {
229 resolveText('A2');
230 });
231 assertLog(['A2', 'Error! [B2]', 'Oops!', 'A2', 'Error! [B2]', 'Oops!']);
232 // Now we can show the error boundary that's wrapped around B.
233 expect(root).toMatchRenderedOutput('A2Oops!');
234 });
235
236 // @gate enableLegacyCache
237 it('errors during a refresh transition should not force fallbacks to display (error then suspend)', async () => {
238 class ErrorBoundary extends React.Component {
239 state = {error: null};
240 static getDerivedStateFromError(error) {
241 return {error};
242 }
243 render() {
244 if (this.state.error !== null) {
245 return <Text text={this.state.error.message} />;
246 }
247 return this.props.children;
248 }
249 }
250
251 function App({step}) {
252 return (
253 <>
254 <Suspense fallback={<Text text="Loading..." />}>
255 <ErrorBoundary>
256 <AsyncText text={'A' + step} />
257 </ErrorBoundary>
258 </Suspense>
259 <Suspense fallback={<Text text="Loading..." />}>
260 <ErrorBoundary>
261 <AsyncText text={'B' + step} />
262 </ErrorBoundary>
263 </Suspense>
264 </>
265 );
266 }
267
268 // Initial render
269 const root = ReactNoop.createRoot();
270 seedNextTextCache('A1');
271 seedNextTextCache('B1');
272 await act(() => {
273 root.render(<App step={1} />);
274 });
275 assertLog(['A1', 'B1']);
276 expect(root).toMatchRenderedOutput('A1B1');
277
278 // Start a refresh transition
279 await act(() => {
280 startTransition(() => {
281 root.render(<App step={2} />);
282 });
283 });
284 assertLog(['Suspend! [A2]', 'Loading...', 'Suspend! [B2]', 'Loading...']);
285 // Because this is a refresh, we don't switch to a fallback
286 expect(root).toMatchRenderedOutput('A1B1');
287
288 // A fails to load.
289 await act(() => {
290 rejectText('A2', new Error('Oops!'));
291 });
292
293 // Because we're still suspended on B, we can't show an error boundary. We
294 // should wait for B to resolve.
295 assertLog(['Error! [A2]', 'Oops!', 'Suspend! [B2]', 'Loading...']);
296 // Remain on previous screen.
297 expect(root).toMatchRenderedOutput('A1B1');
298
299 // B finishes loading.
300 await act(() => {
301 resolveText('B2');
302 });
303 assertLog(['Error! [A2]', 'Oops!', 'B2', 'Error! [A2]', 'Oops!', 'B2']);
304 // Now we can show the error boundary that's wrapped around B.
305 expect(root).toMatchRenderedOutput('Oops!B2');
306 });
307
308 // @gate enableLegacyCache
309 it('suspending in the shell (outside a Suspense boundary) should not throw, warn, or log during a transition', async () => {
310 class ErrorBoundary extends React.Component {
311 state = {error: null};
312 static getDerivedStateFromError(error) {
313 return {error};
314 }
315 render() {
316 if (this.state.error !== null) {
317 return <Text text={this.state.error.message} />;
318 }
319 return this.props.children;
320 }
321 }
322
323 // The initial render suspends without a Suspense boundary. Since it's
324 // wrapped in startTransition, it suspends instead of erroring.
325 const root = ReactNoop.createRoot();
326 await act(() => {
327 startTransition(() => {
328 root.render(<AsyncText text="Async" />);
329 });
330 });
331 assertLog(['Suspend! [Async]']);
332 expect(root).toMatchRenderedOutput(null);
333
334 // This also works if the suspended component is wrapped with an error
335 // boundary. (This is only interesting because when a component suspends
336 // outside of a transition, we throw an error, which can be captured by
337 // an error boundary.
338 await act(() => {
339 startTransition(() => {
340 root.render(
341 <ErrorBoundary>
342 <AsyncText text="Async" />
343 </ErrorBoundary>,
344 );
345 });
346 });
347 assertLog(['Suspend! [Async]']);
348 expect(root).toMatchRenderedOutput(null);
349
350 // Continues rendering once data resolves
351 await act(() => {
352 resolveText('Async');
353 });
354 assertLog(['Async']);
355 expect(root).toMatchRenderedOutput('Async');
356 });
357
358 // @gate enableLegacyCache
359 it(
360 'errors during a suspended transition at the shell should not force ' +
361 'fallbacks to display (error then suspend)',
362 async () => {
363 // This is similar to the earlier test for errors that occur during
364 // a refresh transition. Suspending in the shell is conceptually the same
365 // as a refresh, but they have slightly different implementation paths.
366
367 class ErrorBoundary extends React.Component {
368 state = {error: null};
369 static getDerivedStateFromError(error) {
370 return {error};
371 }
372 render() {
373 if (this.state.error !== null) {
374 return (
375 <Text text={'Caught an error: ' + this.state.error.message} />
376 );
377 }
378 return this.props.children;
379 }
380 }
381
382 function Throws() {
383 throw new Error('Oops!');
384 }
385
386 // Suspend and throw in the same transition
387 const root = ReactNoop.createRoot();
388 await act(() => {
389 startTransition(() => {
390 root.render(
391 <>
392 <AsyncText text="Async" />
393 <ErrorBoundary>
394 <Throws />
395 </ErrorBoundary>
396 </>,
397 );
398 });
399 });
400 assertLog([
401 'Suspend! [Async]',
402 // pre-warming
403 'Caught an error: Oops!',
404 ]);
405 // The render suspended without committing the error.
406 expect(root).toMatchRenderedOutput(null);
407
408 // Try the reverse order, too: throw then suspend
409 await act(() => {
410 startTransition(() => {
411 root.render(
412 <>
413 <AsyncText text="Async" />
414 <ErrorBoundary>
415 <Throws />
416 </ErrorBoundary>
417 </>,
418 );
419 });
420 });
421 assertLog(['Suspend! [Async]', 'Caught an error: Oops!']);
422 expect(root).toMatchRenderedOutput(null);
423
424 await act(async () => {
425 await resolveText('Async');
426 });
427
428 assertLog([
429 'Async',
430 'Caught an error: Oops!',
431
432 // Try recovering from the error by rendering again synchronously
433 'Async',
434 'Caught an error: Oops!',
435 ]);
436
437 expect(root).toMatchRenderedOutput('AsyncCaught an error: Oops!');
438 },
439 );
440 });