main
js 422 lines 11.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 * @emails react-core
8 */
9
10 'use strict';
11
12 let ReactCache;
13 let createResource;
14 let React;
15 let ReactNoop;
16 let Scheduler;
17 let Suspense;
18 let TextResource;
19 let textResourceShouldFail;
20 let waitForAll;
21 let waitForPaint;
22 let assertLog;
23 let waitForThrow;
24 let act;
25 let assertConsoleErrorDev;
26
27 describe('ReactCache', () => {
28 beforeEach(() => {
29 jest.resetModules();
30
31 React = require('react');
32 Suspense = React.Suspense;
33 ReactCache = require('react-cache');
34 createResource = ReactCache.unstable_createResource;
35 ReactNoop = require('react-noop-renderer');
36 Scheduler = require('scheduler');
37
38 const InternalTestUtils = require('internal-test-utils');
39 waitForAll = InternalTestUtils.waitForAll;
40 assertLog = InternalTestUtils.assertLog;
41 waitForThrow = InternalTestUtils.waitForThrow;
42 waitForPaint = InternalTestUtils.waitForPaint;
43 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
44 act = InternalTestUtils.act;
45
46 TextResource = createResource(
47 ([text, ms = 0]) => {
48 let listeners = null;
49 let status = 'pending';
50 let value = null;
51 return {
52 then(resolve, reject) {
53 switch (status) {
54 case 'pending': {
55 if (listeners === null) {
56 listeners = [{resolve, reject}];
57 setTimeout(() => {
58 if (textResourceShouldFail) {
59 Scheduler.log(`Promise rejected [${text}]`);
60 status = 'rejected';
61 value = new Error('Failed to load: ' + text);
62 listeners.forEach(listener => listener.reject(value));
63 } else {
64 Scheduler.log(`Promise resolved [${text}]`);
65 status = 'resolved';
66 value = text;
67 listeners.forEach(listener => listener.resolve(value));
68 }
69 }, ms);
70 } else {
71 listeners.push({resolve, reject});
72 }
73 break;
74 }
75 case 'resolved': {
76 resolve(value);
77 break;
78 }
79 case 'rejected': {
80 reject(value);
81 break;
82 }
83 }
84 },
85 };
86 },
87 ([text, ms]) => text,
88 );
89
90 textResourceShouldFail = false;
91 });
92
93 function Text(props) {
94 Scheduler.log(props.text);
95 return props.text;
96 }
97
98 function AsyncText(props) {
99 const text = props.text;
100 try {
101 TextResource.read([props.text, props.ms]);
102 Scheduler.log(text);
103 return text;
104 } catch (promise) {
105 if (typeof promise.then === 'function') {
106 Scheduler.log(`Suspend! [${text}]`);
107 } else {
108 Scheduler.log(`Error! [${text}]`);
109 }
110 throw promise;
111 }
112 }
113
114 it('throws a promise if the requested value is not in the cache', async () => {
115 function App() {
116 return (
117 <Suspense fallback={<Text text="Loading..." />}>
118 <AsyncText ms={100} text="Hi" />
119 </Suspense>
120 );
121 }
122
123 const root = ReactNoop.createRoot();
124 root.render(<App />);
125
126 await waitForAll([
127 'Suspend! [Hi]',
128 'Loading...',
129 // pre-warming
130 'Suspend! [Hi]',
131 ]);
132
133 jest.advanceTimersByTime(100);
134 assertLog(['Promise resolved [Hi]']);
135 await waitForAll(['Hi']);
136 });
137
138 it('throws an error on the subsequent read if the promise is rejected', async () => {
139 function App() {
140 return (
141 <Suspense fallback={<Text text="Loading..." />}>
142 <AsyncText ms={100} text="Hi" />
143 </Suspense>
144 );
145 }
146
147 const root = ReactNoop.createRoot();
148 root.render(<App />);
149
150 await waitForAll([
151 'Suspend! [Hi]',
152 'Loading...',
153 // pre-warming
154 'Suspend! [Hi]',
155 ]);
156
157 textResourceShouldFail = true;
158 let error;
159 try {
160 await act(() => jest.advanceTimersByTime(100));
161 } catch (e) {
162 error = e;
163 }
164 expect(error.message).toMatch('Failed to load: Hi');
165 assertLog(['Promise rejected [Hi]', 'Error! [Hi]', 'Error! [Hi]']);
166
167 // Should throw again on a subsequent read
168 root.render(<App />);
169 await waitForThrow('Failed to load: Hi');
170 assertLog(['Error! [Hi]', 'Error! [Hi]']);
171 });
172
173 it('warns if non-primitive key is passed to a resource without a hash function', async () => {
174 const BadTextResource = createResource(([text, ms = 0]) => {
175 return new Promise((resolve, reject) =>
176 setTimeout(() => {
177 resolve(text);
178 }, ms),
179 );
180 });
181
182 function App() {
183 Scheduler.log('App');
184 return BadTextResource.read(['Hi', 100]);
185 }
186
187 const root = ReactNoop.createRoot();
188 root.render(
189 <Suspense fallback={<Text text="Loading..." />}>
190 <App />
191 </Suspense>,
192 );
193
194 if (__DEV__) {
195 await waitForAll([
196 'App',
197 'Loading...',
198 // pre-warming
199 'App',
200 ]);
201 assertConsoleErrorDev([
202 'Invalid key type. Expected a string, number, symbol, or ' +
203 "boolean, but instead received: [ 'Hi', 100 ]\n\n" +
204 'To use non-primitive values as keys, you must pass a hash ' +
205 'function as the second argument to createResource().\n' +
206 ' in App (at **)',
207
208 // pre-warming
209 'Invalid key type. Expected a string, number, symbol, or ' +
210 "boolean, but instead received: [ 'Hi', 100 ]\n\n" +
211 'To use non-primitive values as keys, you must pass a hash ' +
212 'function as the second argument to createResource().\n' +
213 ' in App (at **)',
214 ]);
215 } else {
216 await waitForAll([
217 'App',
218 'Loading...',
219 // pre-warming
220 'App',
221 ]);
222 }
223 });
224
225 it('evicts least recently used values', async () => {
226 ReactCache.unstable_setGlobalCacheLimit(3);
227
228 const root = ReactNoop.createRoot();
229 // Render 1, 2, and 3
230 root.render(
231 <Suspense fallback={<Text text="Loading..." />}>
232 <AsyncText ms={100} text={1} />
233 <AsyncText ms={100} text={2} />
234 <AsyncText ms={100} text={3} />
235 </Suspense>,
236 );
237 await waitForPaint(['Suspend! [1]', 'Loading...']);
238 jest.advanceTimersByTime(100);
239 assertLog(['Promise resolved [1]']);
240 await waitForAll([
241 1,
242 'Suspend! [2]',
243 ...(gate('alwaysThrottleRetries')
244 ? []
245 : [1, 'Suspend! [2]', 'Suspend! [3]']),
246 ]);
247
248 jest.advanceTimersByTime(100);
249 assertLog([
250 'Promise resolved [2]',
251 ...(gate('alwaysThrottleRetries') ? [] : ['Promise resolved [3]']),
252 ]);
253 await waitForAll([
254 1,
255 2,
256 ...(gate('alwaysThrottleRetries') ? ['Suspend! [3]'] : [3]),
257 ]);
258
259 jest.advanceTimersByTime(100);
260 assertLog(gate('alwaysThrottleRetries') ? ['Promise resolved [3]'] : []);
261 await waitForAll(gate('alwaysThrottleRetries') ? [1, 2, 3] : []);
262
263 await act(() => jest.advanceTimersByTime(100));
264 expect(root).toMatchRenderedOutput('123');
265
266 // Render 1, 4, 5
267 root.render(
268 <Suspense fallback={<Text text="Loading..." />}>
269 <AsyncText ms={100} text={1} />
270 <AsyncText ms={100} text={4} />
271 <AsyncText ms={100} text={5} />
272 </Suspense>,
273 );
274
275 await waitForAll([
276 1,
277 'Suspend! [4]',
278 'Loading...',
279 1,
280 'Suspend! [4]',
281 'Suspend! [5]',
282 ]);
283
284 await act(() => jest.advanceTimersByTime(100));
285 assertLog(['Promise resolved [4]', 'Promise resolved [5]', 1, 4, 5]);
286
287 expect(root).toMatchRenderedOutput('145');
288
289 // We've now rendered values 1, 2, 3, 4, 5, over our limit of 3. The least
290 // recently used values are 2 and 3. They should have been evicted.
291
292 root.render(
293 <Suspense fallback={<Text text="Loading..." />}>
294 <AsyncText ms={100} text={1} />
295 <AsyncText ms={100} text={2} />
296 <AsyncText ms={100} text={3} />
297 </Suspense>,
298 );
299
300 await waitForAll([
301 // 1 is still cached
302 1,
303 // 2 and 3 suspend because they were evicted from the cache
304 'Suspend! [2]',
305 'Loading...',
306
307 1,
308 'Suspend! [2]',
309 'Suspend! [3]',
310 ]);
311
312 await act(() => jest.advanceTimersByTime(100));
313 assertLog(['Promise resolved [2]', 'Promise resolved [3]', 1, 2, 3]);
314 expect(root).toMatchRenderedOutput('123');
315 });
316
317 it('preloads during the render phase', async () => {
318 function App() {
319 TextResource.preload(['B', 1000]);
320 TextResource.read(['A', 1000]);
321 TextResource.read(['B', 1000]);
322 return <Text text="Result" />;
323 }
324
325 const root = ReactNoop.createRoot();
326 root.render(
327 <Suspense fallback={<Text text="Loading..." />}>
328 <App />
329 </Suspense>,
330 );
331
332 await waitForAll(['Loading...']);
333
334 await act(() => jest.advanceTimersByTime(1000));
335 assertLog(['Promise resolved [B]', 'Promise resolved [A]', 'Result']);
336 expect(root).toMatchRenderedOutput('Result');
337 });
338
339 it('if a thenable resolves multiple times, does not update the first cached value', async () => {
340 let resolveThenable;
341 const BadTextResource = createResource(
342 ([text, ms = 0]) => {
343 let listeners = null;
344 const value = null;
345 return {
346 then(resolve, reject) {
347 if (value !== null) {
348 resolve(value);
349 } else {
350 if (listeners === null) {
351 listeners = [resolve];
352 resolveThenable = v => {
353 listeners.forEach(listener => listener(v));
354 };
355 } else {
356 listeners.push(resolve);
357 }
358 }
359 },
360 };
361 },
362 ([text, ms]) => text,
363 );
364
365 function BadAsyncText(props) {
366 const text = props.text;
367 try {
368 const actualText = BadTextResource.read([props.text, props.ms]);
369 Scheduler.log(actualText);
370 return actualText;
371 } catch (promise) {
372 if (typeof promise.then === 'function') {
373 Scheduler.log(`Suspend! [${text}]`);
374 } else {
375 Scheduler.log(`Error! [${text}]`);
376 }
377 throw promise;
378 }
379 }
380
381 const root = ReactNoop.createRoot();
382 root.render(
383 <Suspense fallback={<Text text="Loading..." />}>
384 <BadAsyncText text="Hi" />
385 </Suspense>,
386 );
387
388 await waitForAll([
389 'Suspend! [Hi]',
390 'Loading...',
391 // pre-warming
392 'Suspend! [Hi]',
393 ]);
394
395 resolveThenable('Hi');
396 // This thenable improperly resolves twice. We should not update the
397 // cached value.
398 resolveThenable('Hi muahahaha I am different');
399
400 root.render(
401 <Suspense fallback={<Text text="Loading..." />}>
402 <BadAsyncText text="Hi" />
403 </Suspense>,
404 );
405
406 assertLog([]);
407 await waitForAll(['Hi']);
408 expect(root).toMatchRenderedOutput('Hi');
409 });
410
411 it('throws if read is called outside render', () => {
412 expect(() => TextResource.read(['A', 1000])).toThrow(
413 "read and preload may only be called from within a component's render",
414 );
415 });
416
417 it('throws if preload is called outside render', () => {
418 expect(() => TextResource.preload(['A', 1000])).toThrow(
419 "read and preload may only be called from within a component's render",
420 );
421 });
422 });