main
js 663 lines 19.1 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 React;
13 let Scheduler;
14 // let ReactCache;
15 let ReactDOM;
16 let ReactDOMClient;
17 // let Suspense;
18 let originalCreateElement;
19 // let TextResource;
20 // let textResourceShouldFail;
21 let originalHTMLImageElementSrcDescriptor;
22
23 let images = [];
24 let onLoadSpy = null;
25 let actualLoadSpy = null;
26
27 let waitForAll;
28 let waitFor;
29 let assertLog;
30
31 function PhaseMarkers({children}) {
32 Scheduler.log('render start');
33 React.useLayoutEffect(() => {
34 Scheduler.log('last layout');
35 });
36 React.useEffect(() => {
37 Scheduler.log('last passive');
38 });
39 return children;
40 }
41
42 function last(arr) {
43 if (Array.isArray(arr)) {
44 if (arr.length) {
45 return arr[arr.length - 1];
46 }
47 return undefined;
48 }
49 throw new Error('last was passed something that was not an array');
50 }
51
52 function Text(props) {
53 Scheduler.log(props.text);
54 return props.text;
55 }
56
57 // function AsyncText(props) {
58 // const text = props.text;
59 // try {
60 // TextResource.read([props.text, props.ms]);
61 // Scheduler.log(text);
62 // return text;
63 // } catch (promise) {
64 // if (typeof promise.then === 'function') {
65 // Scheduler.log(`Suspend! [${text}]`);
66 // } else {
67 // Scheduler.log(`Error! [${text}]`);
68 // }
69 // throw promise;
70 // }
71 // }
72
73 function Img({src: maybeSrc, onLoad, useImageLoader, ref}) {
74 const src = maybeSrc || 'default';
75 Scheduler.log('Img ' + src);
76 return <img src={src} onLoad={onLoad} />;
77 }
78
79 function Yield() {
80 Scheduler.log('Yield');
81 Scheduler.unstable_requestPaint();
82 return null;
83 }
84
85 function loadImage(element) {
86 const event = new Event('load');
87 element.__needsDispatch = false;
88 element.dispatchEvent(event);
89 }
90
91 describe('ReactDOMImageLoad', () => {
92 beforeEach(() => {
93 jest.resetModules();
94 React = require('react');
95 Scheduler = require('scheduler');
96 // ReactCache = require('react-cache');
97 ReactDOM = require('react-dom');
98 ReactDOMClient = require('react-dom/client');
99 // Suspense = React.Suspense;
100
101 const InternalTestUtils = require('internal-test-utils');
102 waitForAll = InternalTestUtils.waitForAll;
103 waitFor = InternalTestUtils.waitFor;
104 assertLog = InternalTestUtils.assertLog;
105
106 onLoadSpy = jest.fn(reactEvent => {
107 const src = reactEvent.target.getAttribute('src');
108 Scheduler.log('onLoadSpy [' + src + ']');
109 });
110
111 actualLoadSpy = jest.fn(nativeEvent => {
112 const src = nativeEvent.target.getAttribute('src');
113 Scheduler.log('actualLoadSpy [' + src + ']');
114 nativeEvent.__originalDispatch = false;
115 });
116
117 // TextResource = ReactCache.unstable_createResource(
118 // ([text, ms = 0]) => {
119 // let listeners = null;
120 // let status = 'pending';
121 // let value = null;
122 // return {
123 // then(resolve, reject) {
124 // switch (status) {
125 // case 'pending': {
126 // if (listeners === null) {
127 // listeners = [{resolve, reject}];
128 // setTimeout(() => {
129 // if (textResourceShouldFail) {
130 // Scheduler.log(
131 // `Promise rejected [${text}]`,
132 // );
133 // status = 'rejected';
134 // value = new Error('Failed to load: ' + text);
135 // listeners.forEach(listener => listener.reject(value));
136 // } else {
137 // Scheduler.log(
138 // `Promise resolved [${text}]`,
139 // );
140 // status = 'resolved';
141 // value = text;
142 // listeners.forEach(listener => listener.resolve(value));
143 // }
144 // }, ms);
145 // } else {
146 // listeners.push({resolve, reject});
147 // }
148 // break;
149 // }
150 // case 'resolved': {
151 // resolve(value);
152 // break;
153 // }
154 // case 'rejected': {
155 // reject(value);
156 // break;
157 // }
158 // }
159 // },
160 // };
161 // },
162 // ([text, ms]) => text,
163 // );
164 // textResourceShouldFail = false;
165
166 images = [];
167
168 originalCreateElement = document.createElement;
169 document.createElement = function createElement(tagName, options) {
170 const element = originalCreateElement.call(document, tagName, options);
171 if (tagName === 'img') {
172 element.addEventListener('load', actualLoadSpy);
173 images.push(element);
174 }
175 return element;
176 };
177
178 originalHTMLImageElementSrcDescriptor = Object.getOwnPropertyDescriptor(
179 HTMLImageElement.prototype,
180 'src',
181 );
182
183 Object.defineProperty(HTMLImageElement.prototype, 'src', {
184 get() {
185 return this.getAttribute('src');
186 },
187 set(value) {
188 Scheduler.log('load triggered');
189 this.__needsDispatch = true;
190 this.setAttribute('src', value);
191 },
192 });
193 });
194
195 afterEach(() => {
196 document.createElement = originalCreateElement;
197 Object.defineProperty(
198 HTMLImageElement.prototype,
199 'src',
200 originalHTMLImageElementSrcDescriptor,
201 );
202 });
203
204 it('captures the load event if it happens before commit phase and replays it between layout and passive effects', async function () {
205 const container = document.createElement('div');
206 const root = ReactDOMClient.createRoot(container);
207
208 React.startTransition(() =>
209 root.render(
210 <PhaseMarkers>
211 <Img onLoad={onLoadSpy} />
212 <Yield />
213 <Text text={'a'} />
214 </PhaseMarkers>,
215 ),
216 );
217
218 await waitFor(['render start', 'Img default', 'Yield']);
219 const img = last(images);
220 loadImage(img);
221 assertLog([
222 'actualLoadSpy [default]',
223 // no onLoadSpy since we have not completed render
224 ]);
225 await waitForAll(['a', 'load triggered', 'last layout', 'last passive']);
226 expect(img.__needsDispatch).toBe(true);
227 loadImage(img);
228 assertLog([
229 'actualLoadSpy [default]', // the browser reloading of the image causes this to yield again
230 'onLoadSpy [default]',
231 ]);
232 expect(onLoadSpy).toHaveBeenCalled();
233 });
234
235 it('captures the load event if it happens after commit phase and replays it', async function () {
236 const container = document.createElement('div');
237 const root = ReactDOMClient.createRoot(container);
238
239 React.startTransition(() =>
240 root.render(
241 <PhaseMarkers>
242 <Img onLoad={onLoadSpy} />
243 </PhaseMarkers>,
244 ),
245 );
246
247 await waitFor([
248 'render start',
249 'Img default',
250 'load triggered',
251 'last layout',
252 ]);
253 Scheduler.unstable_requestPaint();
254 const img = last(images);
255 loadImage(img);
256 assertLog(['actualLoadSpy [default]', 'onLoadSpy [default]']);
257 await waitForAll(['last passive']);
258 expect(img.__needsDispatch).toBe(false);
259 expect(onLoadSpy).toHaveBeenCalledTimes(1);
260 });
261
262 it('replays the last load event when more than one fire before the end of the layout phase completes', async function () {
263 const container = document.createElement('div');
264 const root = ReactDOMClient.createRoot(container);
265
266 function Base() {
267 const [src, setSrc] = React.useState('a');
268 return (
269 <PhaseMarkers>
270 <Img src={src} onLoad={onLoadSpy} />
271 <Yield />
272 <UpdateSrc setSrc={setSrc} />
273 </PhaseMarkers>
274 );
275 }
276
277 function UpdateSrc({setSrc}) {
278 React.useLayoutEffect(() => {
279 setSrc('b');
280 }, [setSrc]);
281 return null;
282 }
283
284 React.startTransition(() => root.render(<Base />));
285
286 await waitFor(['render start', 'Img a', 'Yield']);
287 const img = last(images);
288 loadImage(img);
289 assertLog(['actualLoadSpy [a]']);
290
291 await waitFor([
292 'load triggered',
293 'last layout',
294 // the update in layout causes a passive effects flush before a sync render
295 'last passive',
296 'render start',
297 'Img b',
298 'Yield',
299 // yield is ignored becasue we are sync rendering
300 'last layout',
301 'last passive',
302 ]);
303 expect(images.length).toBe(1);
304 loadImage(img);
305 assertLog(['actualLoadSpy [b]', 'onLoadSpy [b]']);
306 expect(onLoadSpy).toHaveBeenCalledTimes(1);
307 });
308
309 it('replays load events that happen in passive phase after the passive phase.', async function () {
310 const container = document.createElement('div');
311 const root = ReactDOMClient.createRoot(container);
312
313 root.render(
314 <PhaseMarkers>
315 <Img onLoad={onLoadSpy} />
316 </PhaseMarkers>,
317 );
318
319 await waitForAll([
320 'render start',
321 'Img default',
322 'load triggered',
323 'last layout',
324 'last passive',
325 ]);
326 const img = last(images);
327 loadImage(img);
328 assertLog(['actualLoadSpy [default]', 'onLoadSpy [default]']);
329 expect(onLoadSpy).toHaveBeenCalledTimes(1);
330 });
331
332 it('captures and suppresses the load event if it happens before passive effects and a cascading update causes the img to be removed', async function () {
333 const container = document.createElement('div');
334 const root = ReactDOMClient.createRoot(container);
335
336 function ChildSuppressing({children}) {
337 const [showChildren, update] = React.useState(true);
338 React.useLayoutEffect(() => {
339 if (showChildren) {
340 update(false);
341 }
342 }, [showChildren]);
343 return showChildren ? children : null;
344 }
345
346 React.startTransition(() =>
347 root.render(
348 <PhaseMarkers>
349 <ChildSuppressing>
350 <Img onLoad={onLoadSpy} />
351 <Yield />
352 <Text text={'a'} />
353 </ChildSuppressing>
354 </PhaseMarkers>,
355 ),
356 );
357
358 await waitFor(['render start', 'Img default', 'Yield']);
359 const img = last(images);
360 loadImage(img);
361 assertLog(['actualLoadSpy [default]']);
362 await waitForAll(['a', 'load triggered', 'last layout', 'last passive']);
363 expect(img.__needsDispatch).toBe(true);
364 loadImage(img);
365 // we expect the browser to load the image again but since we are no longer rendering
366 // the img there will be no onLoad called
367 assertLog(['actualLoadSpy [default]']);
368 await waitForAll([]);
369 expect(onLoadSpy).not.toHaveBeenCalled();
370 });
371
372 it('captures and suppresses the load event if it happens before passive effects and a cascading update causes the img to be removed, alternate', async function () {
373 const container = document.createElement('div');
374 const root = ReactDOMClient.createRoot(container);
375
376 function Switch({children}) {
377 const [shouldShow, updateShow] = React.useState(true);
378 return children(shouldShow, updateShow);
379 }
380
381 function UpdateSwitchInLayout({updateShow}) {
382 React.useLayoutEffect(() => {
383 updateShow(false);
384 }, []);
385 return null;
386 }
387
388 React.startTransition(() =>
389 root.render(
390 <Switch>
391 {(shouldShow, updateShow) => (
392 <PhaseMarkers>
393 <>
394 {shouldShow === true ? (
395 <>
396 <Img onLoad={onLoadSpy} />
397 <Yield />
398 <Text text={'a'} />
399 </>
400 ) : null}
401 ,
402 <UpdateSwitchInLayout updateShow={updateShow} />
403 </>
404 </PhaseMarkers>
405 )}
406 </Switch>,
407 ),
408 );
409
410 await waitFor([
411 // initial render
412 'render start',
413 'Img default',
414 'Yield',
415 ]);
416 const img = last(images);
417 loadImage(img);
418 assertLog(['actualLoadSpy [default]']);
419 await waitForAll([
420 'a',
421 'load triggered',
422 // img is present at first
423 'last layout',
424 'last passive',
425 // sync re-render where the img is suppressed
426 'render start',
427 'last layout',
428 'last passive',
429 ]);
430 expect(img.__needsDispatch).toBe(true);
431 loadImage(img);
432 // we expect the browser to load the image again but since we are no longer rendering
433 // the img there will be no onLoad called
434 assertLog(['actualLoadSpy [default]']);
435 await waitForAll([]);
436 expect(onLoadSpy).not.toHaveBeenCalled();
437 });
438
439 // eslint-disable-next-line jest/no-commented-out-tests
440 // it('captures the load event if it happens in a suspended subtree and replays it between layout and passive effects on resumption', async function() {
441 // function SuspendingWithImage() {
442 // Scheduler.log('SuspendingWithImage');
443 // return (
444 // <Suspense fallback={<Text text="Loading..." />}>
445 // <AsyncText text="A" ms={100} />
446 // <PhaseMarkers>
447 // <Img onLoad={onLoadSpy} />
448 // </PhaseMarkers>
449 // </Suspense>
450 // );
451 // }
452
453 // const container = document.createElement('div');
454 // const root = ReactDOMClient.createRoot(container);
455
456 // React.startTransition(() => root.render(<SuspendingWithImage />));
457
458 // expect(Scheduler).toFlushAndYield([
459 // 'SuspendingWithImage',
460 // 'Suspend! [A]',
461 // 'render start',
462 // 'Img default',
463 // 'Loading...',
464 // ]);
465 // let img = last(images);
466 // loadImage(img);
467 // expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
468 // expect(onLoadSpy).not.toHaveBeenCalled();
469
470 // // Flush some of the time
471 // jest.advanceTimersByTime(50);
472 // // Still nothing...
473 // expect(Scheduler).toFlushWithoutYielding();
474
475 // // Flush the promise completely
476 // jest.advanceTimersByTime(50);
477 // // Renders successfully
478 // expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
479
480 // expect(Scheduler).toFlushAndYieldThrough([
481 // 'A',
482 // // img was recreated on unsuspended tree causing new load event
483 // 'render start',
484 // 'Img default',
485 // 'last layout',
486 // ]);
487
488 // expect(images.length).toBe(2);
489 // img = last(images);
490 // expect(img.__needsDispatch).toBe(true);
491 // loadImage(img);
492 // expect(Scheduler).toHaveYielded([
493 // 'actualLoadSpy [default]',
494 // 'onLoadSpy [default]',
495 // ]);
496
497 // expect(Scheduler).toFlushAndYield(['last passive']);
498
499 // expect(onLoadSpy).toHaveBeenCalledTimes(1);
500 // });
501
502 it('correctly replays the last img load even when a yield + update causes the host element to change', async function () {
503 let externalSetSrc = null;
504 let externalSetSrcAlt = null;
505
506 function Base() {
507 const [src, setSrc] = React.useState(null);
508 const [srcAlt, setSrcAlt] = React.useState(null);
509 externalSetSrc = setSrc;
510 externalSetSrcAlt = setSrcAlt;
511 return srcAlt || src ? <YieldingWithImage src={srcAlt || src} /> : null;
512 }
513
514 function YieldingWithImage({src}) {
515 Scheduler.log('YieldingWithImage');
516 React.useEffect(() => {
517 Scheduler.log('Committed');
518 });
519 return (
520 <>
521 <Img src={src} onLoad={onLoadSpy} />
522 <Yield />
523 <Text text={src} />
524 </>
525 );
526 }
527
528 const container = document.createElement('div');
529 const root = ReactDOMClient.createRoot(container);
530
531 root.render(<Base />);
532
533 await waitForAll([]);
534
535 React.startTransition(() => externalSetSrc('a'));
536
537 await waitFor(['YieldingWithImage', 'Img a', 'Yield']);
538 let img = last(images);
539 loadImage(img);
540 assertLog(['actualLoadSpy [a]']);
541
542 ReactDOM.flushSync(() => externalSetSrcAlt('b'));
543
544 assertLog([
545 'YieldingWithImage',
546 'Img b',
547 'Yield',
548 'b',
549 'load triggered',
550 'Committed',
551 ]);
552 expect(images.length).toBe(2);
553 img = last(images);
554 expect(img.__needsDispatch).toBe(true);
555 loadImage(img);
556
557 assertLog(['actualLoadSpy [b]', 'onLoadSpy [b]']);
558 // why is there another update here?
559 await waitForAll(['YieldingWithImage', 'Img b', 'Yield', 'b', 'Committed']);
560 });
561
562 it('preserves the src property / attribute when triggering a potential new load event', async () => {
563 // this test covers a regression identified in https://github.com/mui/material-ui/pull/31263
564 // where the resetting of the src property caused the property to change from relative to fully qualified
565
566 // make sure we are not using the patched src setter
567 Object.defineProperty(
568 HTMLImageElement.prototype,
569 'src',
570 originalHTMLImageElementSrcDescriptor,
571 );
572
573 const container = document.createElement('div');
574 const root = ReactDOMClient.createRoot(container);
575
576 React.startTransition(() =>
577 root.render(
578 <PhaseMarkers>
579 <Img onLoad={onLoadSpy} />
580 <Yield />
581 <Text text={'a'} />
582 </PhaseMarkers>,
583 ),
584 );
585
586 // render to yield to capture state of img src attribute and property before commit
587 await waitFor(['render start', 'Img default', 'Yield']);
588 const img = last(images);
589 const renderSrcProperty = img.src;
590 const renderSrcAttr = img.getAttribute('src');
591
592 // finish render and commit causing the src property to be rewritten
593 await waitForAll(['a', 'last layout', 'last passive']);
594 const commitSrcProperty = img.src;
595 const commitSrcAttr = img.getAttribute('src');
596
597 // ensure attribute and properties agree
598 expect(renderSrcProperty).toBe(commitSrcProperty);
599 expect(renderSrcAttr).toBe(commitSrcAttr);
600 });
601
602 it('captures the load event for Blob sources if it happens before commit phase', async function () {
603 const container = document.createElement('div');
604 const root = ReactDOMClient.createRoot(container);
605
606 const blob = new Blob();
607
608 React.startTransition(() =>
609 root.render(
610 <PhaseMarkers>
611 <Img src={blob} onLoad={onLoadSpy} />
612 <Yield />
613 <Text text={'a'} />
614 </PhaseMarkers>,
615 ),
616 );
617
618 await waitFor(['render start', 'Img [object Blob]', 'Yield']);
619 const img = last(images);
620 loadImage(img);
621 assertLog([
622 'actualLoadSpy [[object Blob]]',
623 // no onLoadSpy since we have not completed render
624 ]);
625 await waitForAll(['a', 'load triggered', 'last layout', 'last passive']);
626 expect(img.__needsDispatch).toBe(true);
627 loadImage(img);
628 assertLog([
629 'actualLoadSpy [[object Blob]]', // the browser reloading of the image causes this to yield again
630 'onLoadSpy [[object Blob]]',
631 ]);
632 expect(onLoadSpy).toHaveBeenCalled();
633 });
634
635 it('captures the load event for Blob sources if it happens after commit phase and replays it', async function () {
636 const container = document.createElement('div');
637 const root = ReactDOMClient.createRoot(container);
638
639 const blob = new Blob();
640
641 React.startTransition(() =>
642 root.render(
643 <PhaseMarkers>
644 <Img src={blob} onLoad={onLoadSpy} />
645 </PhaseMarkers>,
646 ),
647 );
648
649 await waitFor([
650 'render start',
651 'Img [object Blob]',
652 'load triggered',
653 'last layout',
654 ]);
655 Scheduler.unstable_requestPaint();
656 const img = last(images);
657 loadImage(img);
658 assertLog(['actualLoadSpy [[object Blob]]', 'onLoadSpy [[object Blob]]']);
659 await waitForAll(['last passive']);
660 expect(img.__needsDispatch).toBe(false);
661 expect(onLoadSpy).toHaveBeenCalledTimes(1);
662 });
663 });