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('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.   // it('captures the load event if it happens in a suspended subtree and replays it between layout and passive effects on resumption', async function() {
    
  440.   //   function SuspendingWithImage() {
    
  441.   //     Scheduler.log('SuspendingWithImage');
    
  442.   //     return (
    
  443.   //       <Suspense fallback={<Text text="Loading..." />}>
    
  444.   //         <AsyncText text="A" ms={100} />
    
  445.   //         <PhaseMarkers>
    
  446.   //           <Img onLoad={onLoadSpy} />
    
  447.   //         </PhaseMarkers>
    
  448.   //       </Suspense>
    
  449.   //     );
    
  450.   //   }
    
  451. 
    
  452.   //   const container = document.createElement('div');
    
  453.   //   const root = ReactDOMClient.createRoot(container);
    
  454. 
    
  455.   //   React.startTransition(() => root.render(<SuspendingWithImage />));
    
  456. 
    
  457.   //   expect(Scheduler).toFlushAndYield([
    
  458.   //     'SuspendingWithImage',
    
  459.   //     'Suspend! [A]',
    
  460.   //     'render start',
    
  461.   //     'Img default',
    
  462.   //     'Loading...',
    
  463.   //   ]);
    
  464.   //   let img = last(images);
    
  465.   //   loadImage(img);
    
  466.   //   expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
    
  467.   //   expect(onLoadSpy).not.toHaveBeenCalled();
    
  468. 
    
  469.   //   // Flush some of the time
    
  470.   //   jest.advanceTimersByTime(50);
    
  471.   //   // Still nothing...
    
  472.   //   expect(Scheduler).toFlushWithoutYielding();
    
  473. 
    
  474.   //   // Flush the promise completely
    
  475.   //   jest.advanceTimersByTime(50);
    
  476.   //   // Renders successfully
    
  477.   //   expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
    
  478. 
    
  479.   //   expect(Scheduler).toFlushAndYieldThrough([
    
  480.   //     'A',
    
  481.   //     // img was recreated on unsuspended tree causing new load event
    
  482.   //     'render start',
    
  483.   //     'Img default',
    
  484.   //     'last layout',
    
  485.   //   ]);
    
  486. 
    
  487.   //   expect(images.length).toBe(2);
    
  488.   //   img = last(images);
    
  489.   //   expect(img.__needsDispatch).toBe(true);
    
  490.   //   loadImage(img);
    
  491.   //   expect(Scheduler).toHaveYielded([
    
  492.   //     'actualLoadSpy [default]',
    
  493.   //     'onLoadSpy [default]',
    
  494.   //   ]);
    
  495. 
    
  496.   //   expect(Scheduler).toFlushAndYield(['last passive']);
    
  497. 
    
  498.   //   expect(onLoadSpy).toHaveBeenCalledTimes(1);
    
  499.   // });
    
  500. 
    
  501.   it('correctly replays the last img load even when a yield + update causes the host element to change', async function () {
    
  502.     let externalSetSrc = null;
    
  503.     let externalSetSrcAlt = null;
    
  504. 
    
  505.     function Base() {
    
  506.       const [src, setSrc] = React.useState(null);
    
  507.       const [srcAlt, setSrcAlt] = React.useState(null);
    
  508.       externalSetSrc = setSrc;
    
  509.       externalSetSrcAlt = setSrcAlt;
    
  510.       return srcAlt || src ? <YieldingWithImage src={srcAlt || src} /> : null;
    
  511.     }
    
  512. 
    
  513.     function YieldingWithImage({src}) {
    
  514.       Scheduler.log('YieldingWithImage');
    
  515.       React.useEffect(() => {
    
  516.         Scheduler.log('Committed');
    
  517.       });
    
  518.       return (
    
  519.         <>
    
  520.           <Img src={src} onLoad={onLoadSpy} />
    
  521.           <Yield />
    
  522.           <Text text={src} />
    
  523.         </>
    
  524.       );
    
  525.     }
    
  526. 
    
  527.     const container = document.createElement('div');
    
  528.     const root = ReactDOMClient.createRoot(container);
    
  529. 
    
  530.     root.render(<Base />);
    
  531. 
    
  532.     await waitForAll([]);
    
  533. 
    
  534.     React.startTransition(() => externalSetSrc('a'));
    
  535. 
    
  536.     await waitFor(['YieldingWithImage', 'Img a', 'Yield']);
    
  537.     let img = last(images);
    
  538.     loadImage(img);
    
  539.     assertLog(['actualLoadSpy [a]']);
    
  540. 
    
  541.     ReactDOM.flushSync(() => externalSetSrcAlt('b'));
    
  542. 
    
  543.     assertLog([
    
  544.       'YieldingWithImage',
    
  545.       'Img b',
    
  546.       'Yield',
    
  547.       'b',
    
  548.       'load triggered',
    
  549.       'Committed',
    
  550.     ]);
    
  551.     expect(images.length).toBe(2);
    
  552.     img = last(images);
    
  553.     expect(img.__needsDispatch).toBe(true);
    
  554.     loadImage(img);
    
  555. 
    
  556.     assertLog(['actualLoadSpy [b]', 'onLoadSpy [b]']);
    
  557.     // why is there another update here?
    
  558.     await waitForAll(['YieldingWithImage', 'Img b', 'Yield', 'b', 'Committed']);
    
  559.   });
    
  560. 
    
  561.   it('preserves the src property / attribute when triggering a potential new load event', async () => {
    
  562.     // this test covers a regression identified in https://github.com/mui/material-ui/pull/31263
    
  563.     // where the resetting of the src property caused the property to change from relative to fully qualified
    
  564. 
    
  565.     // make sure we are not using the patched src setter
    
  566.     Object.defineProperty(
    
  567.       HTMLImageElement.prototype,
    
  568.       'src',
    
  569.       originalHTMLImageElementSrcDescriptor,
    
  570.     );
    
  571. 
    
  572.     const container = document.createElement('div');
    
  573.     const root = ReactDOMClient.createRoot(container);
    
  574. 
    
  575.     React.startTransition(() =>
    
  576.       root.render(
    
  577.         <PhaseMarkers>
    
  578.           <Img onLoad={onLoadSpy} />
    
  579.           <Yield />
    
  580.           <Text text={'a'} />
    
  581.         </PhaseMarkers>,
    
  582.       ),
    
  583.     );
    
  584. 
    
  585.     // render to yield to capture state of img src attribute and property before commit
    
  586.     await waitFor(['render start', 'Img default', 'Yield']);
    
  587.     const img = last(images);
    
  588.     const renderSrcProperty = img.src;
    
  589.     const renderSrcAttr = img.getAttribute('src');
    
  590. 
    
  591.     // finish render and commit causing the src property to be rewritten
    
  592.     await waitForAll(['a', 'last layout', 'last passive']);
    
  593.     const commitSrcProperty = img.src;
    
  594.     const commitSrcAttr = img.getAttribute('src');
    
  595. 
    
  596.     // ensure attribute and properties agree
    
  597.     expect(renderSrcProperty).toBe(commitSrcProperty);
    
  598.     expect(renderSrcAttr).toBe(commitSrcAttr);
    
  599.   });
    
  600. });