mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
ea05b750a5
Behind the `enableSrcObject` flag. This is revisiting a variant of what was discussed in #11163. Instead of supporting the [`srcObject` property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject) as a separate name, this adds an overload of `src` to allow objects to be passed. The DOM needs to add separate properties for the object forms since you read back but it doesn't make sense for React's write-only API to do that. Similar to how we'll like add an overload for `popoverTarget` instead of calling it `popoverTargetElement` and how `style` accepts an object and it's not `styleObject={{...}}`. There are a number of reason to revisit this. - It's just way more convenient to have this built-in and it makes conceptual sense. We typically support declarative APIs and polyfill them when necessary. - RSC supports Blobs and by having it built-in you don't need a Client Component wrapper to render it where as doing it with effects would require more complex wrappers. By picking Blobs over base64, client-navigations can use the more optimized binary encoding in the RSC protocol. - The timing aspect of coordinating it with Suspensey images and image decoding is a bit tricky to get right because if you set it in an effect it's too late because you've already rendered it. - SSR gets complicated when done in user space because you have to handle both branches. Likely with `useSyncExternalStore`. - By having it built-in we could optimize the payloads shared between RSC payloads embedded in the HTML and data URLs. This does not support objects for `<source src>` nor `<img srcset>`. Those don't really have equivalents in the DOM neither. They're mainly for picking an option when you don't know programmatically. However, for this use case you're really better off picking a variant before generating the blobs. We may support Response objects in the future too as per https://github.com/whatwg/fetch/issues/49
664 lines
19 KiB
JavaScript
664 lines
19 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @emails react-core
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
let React;
|
|
let Scheduler;
|
|
// let ReactCache;
|
|
let ReactDOM;
|
|
let ReactDOMClient;
|
|
// let Suspense;
|
|
let originalCreateElement;
|
|
// let TextResource;
|
|
// let textResourceShouldFail;
|
|
let originalHTMLImageElementSrcDescriptor;
|
|
|
|
let images = [];
|
|
let onLoadSpy = null;
|
|
let actualLoadSpy = null;
|
|
|
|
let waitForAll;
|
|
let waitFor;
|
|
let assertLog;
|
|
|
|
function PhaseMarkers({children}) {
|
|
Scheduler.log('render start');
|
|
React.useLayoutEffect(() => {
|
|
Scheduler.log('last layout');
|
|
});
|
|
React.useEffect(() => {
|
|
Scheduler.log('last passive');
|
|
});
|
|
return children;
|
|
}
|
|
|
|
function last(arr) {
|
|
if (Array.isArray(arr)) {
|
|
if (arr.length) {
|
|
return arr[arr.length - 1];
|
|
}
|
|
return undefined;
|
|
}
|
|
throw new Error('last was passed something that was not an array');
|
|
}
|
|
|
|
function Text(props) {
|
|
Scheduler.log(props.text);
|
|
return props.text;
|
|
}
|
|
|
|
// function AsyncText(props) {
|
|
// const text = props.text;
|
|
// try {
|
|
// TextResource.read([props.text, props.ms]);
|
|
// Scheduler.log(text);
|
|
// return text;
|
|
// } catch (promise) {
|
|
// if (typeof promise.then === 'function') {
|
|
// Scheduler.log(`Suspend! [${text}]`);
|
|
// } else {
|
|
// Scheduler.log(`Error! [${text}]`);
|
|
// }
|
|
// throw promise;
|
|
// }
|
|
// }
|
|
|
|
function Img({src: maybeSrc, onLoad, useImageLoader, ref}) {
|
|
const src = maybeSrc || 'default';
|
|
Scheduler.log('Img ' + src);
|
|
return <img src={src} onLoad={onLoad} />;
|
|
}
|
|
|
|
function Yield() {
|
|
Scheduler.log('Yield');
|
|
Scheduler.unstable_requestPaint();
|
|
return null;
|
|
}
|
|
|
|
function loadImage(element) {
|
|
const event = new Event('load');
|
|
element.__needsDispatch = false;
|
|
element.dispatchEvent(event);
|
|
}
|
|
|
|
describe('ReactDOMImageLoad', () => {
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
React = require('react');
|
|
Scheduler = require('scheduler');
|
|
// ReactCache = require('react-cache');
|
|
ReactDOM = require('react-dom');
|
|
ReactDOMClient = require('react-dom/client');
|
|
// Suspense = React.Suspense;
|
|
|
|
const InternalTestUtils = require('internal-test-utils');
|
|
waitForAll = InternalTestUtils.waitForAll;
|
|
waitFor = InternalTestUtils.waitFor;
|
|
assertLog = InternalTestUtils.assertLog;
|
|
|
|
onLoadSpy = jest.fn(reactEvent => {
|
|
const src = reactEvent.target.getAttribute('src');
|
|
Scheduler.log('onLoadSpy [' + src + ']');
|
|
});
|
|
|
|
actualLoadSpy = jest.fn(nativeEvent => {
|
|
const src = nativeEvent.target.getAttribute('src');
|
|
Scheduler.log('actualLoadSpy [' + src + ']');
|
|
nativeEvent.__originalDispatch = false;
|
|
});
|
|
|
|
// TextResource = ReactCache.unstable_createResource(
|
|
// ([text, ms = 0]) => {
|
|
// let listeners = null;
|
|
// let status = 'pending';
|
|
// let value = null;
|
|
// return {
|
|
// then(resolve, reject) {
|
|
// switch (status) {
|
|
// case 'pending': {
|
|
// if (listeners === null) {
|
|
// listeners = [{resolve, reject}];
|
|
// setTimeout(() => {
|
|
// if (textResourceShouldFail) {
|
|
// Scheduler.log(
|
|
// `Promise rejected [${text}]`,
|
|
// );
|
|
// status = 'rejected';
|
|
// value = new Error('Failed to load: ' + text);
|
|
// listeners.forEach(listener => listener.reject(value));
|
|
// } else {
|
|
// Scheduler.log(
|
|
// `Promise resolved [${text}]`,
|
|
// );
|
|
// status = 'resolved';
|
|
// value = text;
|
|
// listeners.forEach(listener => listener.resolve(value));
|
|
// }
|
|
// }, ms);
|
|
// } else {
|
|
// listeners.push({resolve, reject});
|
|
// }
|
|
// break;
|
|
// }
|
|
// case 'resolved': {
|
|
// resolve(value);
|
|
// break;
|
|
// }
|
|
// case 'rejected': {
|
|
// reject(value);
|
|
// break;
|
|
// }
|
|
// }
|
|
// },
|
|
// };
|
|
// },
|
|
// ([text, ms]) => text,
|
|
// );
|
|
// textResourceShouldFail = false;
|
|
|
|
images = [];
|
|
|
|
originalCreateElement = document.createElement;
|
|
document.createElement = function createElement(tagName, options) {
|
|
const element = originalCreateElement.call(document, tagName, options);
|
|
if (tagName === 'img') {
|
|
element.addEventListener('load', actualLoadSpy);
|
|
images.push(element);
|
|
}
|
|
return element;
|
|
};
|
|
|
|
originalHTMLImageElementSrcDescriptor = Object.getOwnPropertyDescriptor(
|
|
HTMLImageElement.prototype,
|
|
'src',
|
|
);
|
|
|
|
Object.defineProperty(HTMLImageElement.prototype, 'src', {
|
|
get() {
|
|
return this.getAttribute('src');
|
|
},
|
|
set(value) {
|
|
Scheduler.log('load triggered');
|
|
this.__needsDispatch = true;
|
|
this.setAttribute('src', value);
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.createElement = originalCreateElement;
|
|
Object.defineProperty(
|
|
HTMLImageElement.prototype,
|
|
'src',
|
|
originalHTMLImageElementSrcDescriptor,
|
|
);
|
|
});
|
|
|
|
it('captures the load event if it happens before commit phase and replays it between layout and passive effects', async function () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<Img onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<Text text={'a'} />
|
|
</PhaseMarkers>,
|
|
),
|
|
);
|
|
|
|
await waitFor(['render start', 'Img default', 'Yield']);
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog([
|
|
'actualLoadSpy [default]',
|
|
// no onLoadSpy since we have not completed render
|
|
]);
|
|
await waitForAll(['a', 'load triggered', 'last layout', 'last passive']);
|
|
expect(img.__needsDispatch).toBe(true);
|
|
loadImage(img);
|
|
assertLog([
|
|
'actualLoadSpy [default]', // the browser reloading of the image causes this to yield again
|
|
'onLoadSpy [default]',
|
|
]);
|
|
expect(onLoadSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('captures the load event if it happens after commit phase and replays it', async function () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<Img onLoad={onLoadSpy} />
|
|
</PhaseMarkers>,
|
|
),
|
|
);
|
|
|
|
await waitFor([
|
|
'render start',
|
|
'Img default',
|
|
'load triggered',
|
|
'last layout',
|
|
]);
|
|
Scheduler.unstable_requestPaint();
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [default]', 'onLoadSpy [default]']);
|
|
await waitForAll(['last passive']);
|
|
expect(img.__needsDispatch).toBe(false);
|
|
expect(onLoadSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('replays the last load event when more than one fire before the end of the layout phase completes', async function () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
function Base() {
|
|
const [src, setSrc] = React.useState('a');
|
|
return (
|
|
<PhaseMarkers>
|
|
<Img src={src} onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<UpdateSrc setSrc={setSrc} />
|
|
</PhaseMarkers>
|
|
);
|
|
}
|
|
|
|
function UpdateSrc({setSrc}) {
|
|
React.useLayoutEffect(() => {
|
|
setSrc('b');
|
|
}, [setSrc]);
|
|
return null;
|
|
}
|
|
|
|
React.startTransition(() => root.render(<Base />));
|
|
|
|
await waitFor(['render start', 'Img a', 'Yield']);
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [a]']);
|
|
|
|
await waitFor([
|
|
'load triggered',
|
|
'last layout',
|
|
// the update in layout causes a passive effects flush before a sync render
|
|
'last passive',
|
|
'render start',
|
|
'Img b',
|
|
'Yield',
|
|
// yield is ignored becasue we are sync rendering
|
|
'last layout',
|
|
'last passive',
|
|
]);
|
|
expect(images.length).toBe(1);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [b]', 'onLoadSpy [b]']);
|
|
expect(onLoadSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('replays load events that happen in passive phase after the passive phase.', async function () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<Img onLoad={onLoadSpy} />
|
|
</PhaseMarkers>,
|
|
);
|
|
|
|
await waitForAll([
|
|
'render start',
|
|
'Img default',
|
|
'load triggered',
|
|
'last layout',
|
|
'last passive',
|
|
]);
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [default]', 'onLoadSpy [default]']);
|
|
expect(onLoadSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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 () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
function ChildSuppressing({children}) {
|
|
const [showChildren, update] = React.useState(true);
|
|
React.useLayoutEffect(() => {
|
|
if (showChildren) {
|
|
update(false);
|
|
}
|
|
}, [showChildren]);
|
|
return showChildren ? children : null;
|
|
}
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<ChildSuppressing>
|
|
<Img onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<Text text={'a'} />
|
|
</ChildSuppressing>
|
|
</PhaseMarkers>,
|
|
),
|
|
);
|
|
|
|
await waitFor(['render start', 'Img default', 'Yield']);
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [default]']);
|
|
await waitForAll(['a', 'load triggered', 'last layout', 'last passive']);
|
|
expect(img.__needsDispatch).toBe(true);
|
|
loadImage(img);
|
|
// we expect the browser to load the image again but since we are no longer rendering
|
|
// the img there will be no onLoad called
|
|
assertLog(['actualLoadSpy [default]']);
|
|
await waitForAll([]);
|
|
expect(onLoadSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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 () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
function Switch({children}) {
|
|
const [shouldShow, updateShow] = React.useState(true);
|
|
return children(shouldShow, updateShow);
|
|
}
|
|
|
|
function UpdateSwitchInLayout({updateShow}) {
|
|
React.useLayoutEffect(() => {
|
|
updateShow(false);
|
|
}, []);
|
|
return null;
|
|
}
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<Switch>
|
|
{(shouldShow, updateShow) => (
|
|
<PhaseMarkers>
|
|
<>
|
|
{shouldShow === true ? (
|
|
<>
|
|
<Img onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<Text text={'a'} />
|
|
</>
|
|
) : null}
|
|
,
|
|
<UpdateSwitchInLayout updateShow={updateShow} />
|
|
</>
|
|
</PhaseMarkers>
|
|
)}
|
|
</Switch>,
|
|
),
|
|
);
|
|
|
|
await waitFor([
|
|
// initial render
|
|
'render start',
|
|
'Img default',
|
|
'Yield',
|
|
]);
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [default]']);
|
|
await waitForAll([
|
|
'a',
|
|
'load triggered',
|
|
// img is present at first
|
|
'last layout',
|
|
'last passive',
|
|
// sync re-render where the img is suppressed
|
|
'render start',
|
|
'last layout',
|
|
'last passive',
|
|
]);
|
|
expect(img.__needsDispatch).toBe(true);
|
|
loadImage(img);
|
|
// we expect the browser to load the image again but since we are no longer rendering
|
|
// the img there will be no onLoad called
|
|
assertLog(['actualLoadSpy [default]']);
|
|
await waitForAll([]);
|
|
expect(onLoadSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// eslint-disable-next-line jest/no-commented-out-tests
|
|
// it('captures the load event if it happens in a suspended subtree and replays it between layout and passive effects on resumption', async function() {
|
|
// function SuspendingWithImage() {
|
|
// Scheduler.log('SuspendingWithImage');
|
|
// return (
|
|
// <Suspense fallback={<Text text="Loading..." />}>
|
|
// <AsyncText text="A" ms={100} />
|
|
// <PhaseMarkers>
|
|
// <Img onLoad={onLoadSpy} />
|
|
// </PhaseMarkers>
|
|
// </Suspense>
|
|
// );
|
|
// }
|
|
|
|
// const container = document.createElement('div');
|
|
// const root = ReactDOMClient.createRoot(container);
|
|
|
|
// React.startTransition(() => root.render(<SuspendingWithImage />));
|
|
|
|
// expect(Scheduler).toFlushAndYield([
|
|
// 'SuspendingWithImage',
|
|
// 'Suspend! [A]',
|
|
// 'render start',
|
|
// 'Img default',
|
|
// 'Loading...',
|
|
// ]);
|
|
// let img = last(images);
|
|
// loadImage(img);
|
|
// expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
|
|
// expect(onLoadSpy).not.toHaveBeenCalled();
|
|
|
|
// // Flush some of the time
|
|
// jest.advanceTimersByTime(50);
|
|
// // Still nothing...
|
|
// expect(Scheduler).toFlushWithoutYielding();
|
|
|
|
// // Flush the promise completely
|
|
// jest.advanceTimersByTime(50);
|
|
// // Renders successfully
|
|
// expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
|
|
|
|
// expect(Scheduler).toFlushAndYieldThrough([
|
|
// 'A',
|
|
// // img was recreated on unsuspended tree causing new load event
|
|
// 'render start',
|
|
// 'Img default',
|
|
// 'last layout',
|
|
// ]);
|
|
|
|
// expect(images.length).toBe(2);
|
|
// img = last(images);
|
|
// expect(img.__needsDispatch).toBe(true);
|
|
// loadImage(img);
|
|
// expect(Scheduler).toHaveYielded([
|
|
// 'actualLoadSpy [default]',
|
|
// 'onLoadSpy [default]',
|
|
// ]);
|
|
|
|
// expect(Scheduler).toFlushAndYield(['last passive']);
|
|
|
|
// expect(onLoadSpy).toHaveBeenCalledTimes(1);
|
|
// });
|
|
|
|
it('correctly replays the last img load even when a yield + update causes the host element to change', async function () {
|
|
let externalSetSrc = null;
|
|
let externalSetSrcAlt = null;
|
|
|
|
function Base() {
|
|
const [src, setSrc] = React.useState(null);
|
|
const [srcAlt, setSrcAlt] = React.useState(null);
|
|
externalSetSrc = setSrc;
|
|
externalSetSrcAlt = setSrcAlt;
|
|
return srcAlt || src ? <YieldingWithImage src={srcAlt || src} /> : null;
|
|
}
|
|
|
|
function YieldingWithImage({src}) {
|
|
Scheduler.log('YieldingWithImage');
|
|
React.useEffect(() => {
|
|
Scheduler.log('Committed');
|
|
});
|
|
return (
|
|
<>
|
|
<Img src={src} onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<Text text={src} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
root.render(<Base />);
|
|
|
|
await waitForAll([]);
|
|
|
|
React.startTransition(() => externalSetSrc('a'));
|
|
|
|
await waitFor(['YieldingWithImage', 'Img a', 'Yield']);
|
|
let img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [a]']);
|
|
|
|
ReactDOM.flushSync(() => externalSetSrcAlt('b'));
|
|
|
|
assertLog([
|
|
'YieldingWithImage',
|
|
'Img b',
|
|
'Yield',
|
|
'b',
|
|
'load triggered',
|
|
'Committed',
|
|
]);
|
|
expect(images.length).toBe(2);
|
|
img = last(images);
|
|
expect(img.__needsDispatch).toBe(true);
|
|
loadImage(img);
|
|
|
|
assertLog(['actualLoadSpy [b]', 'onLoadSpy [b]']);
|
|
// why is there another update here?
|
|
await waitForAll(['YieldingWithImage', 'Img b', 'Yield', 'b', 'Committed']);
|
|
});
|
|
|
|
it('preserves the src property / attribute when triggering a potential new load event', async () => {
|
|
// this test covers a regression identified in https://github.com/mui/material-ui/pull/31263
|
|
// where the resetting of the src property caused the property to change from relative to fully qualified
|
|
|
|
// make sure we are not using the patched src setter
|
|
Object.defineProperty(
|
|
HTMLImageElement.prototype,
|
|
'src',
|
|
originalHTMLImageElementSrcDescriptor,
|
|
);
|
|
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<Img onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<Text text={'a'} />
|
|
</PhaseMarkers>,
|
|
),
|
|
);
|
|
|
|
// render to yield to capture state of img src attribute and property before commit
|
|
await waitFor(['render start', 'Img default', 'Yield']);
|
|
const img = last(images);
|
|
const renderSrcProperty = img.src;
|
|
const renderSrcAttr = img.getAttribute('src');
|
|
|
|
// finish render and commit causing the src property to be rewritten
|
|
await waitForAll(['a', 'last layout', 'last passive']);
|
|
const commitSrcProperty = img.src;
|
|
const commitSrcAttr = img.getAttribute('src');
|
|
|
|
// ensure attribute and properties agree
|
|
expect(renderSrcProperty).toBe(commitSrcProperty);
|
|
expect(renderSrcAttr).toBe(commitSrcAttr);
|
|
});
|
|
|
|
it('captures the load event for Blob sources if it happens before commit phase', async function () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
const blob = new Blob();
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<Img src={blob} onLoad={onLoadSpy} />
|
|
<Yield />
|
|
<Text text={'a'} />
|
|
</PhaseMarkers>,
|
|
),
|
|
);
|
|
|
|
await waitFor(['render start', 'Img [object Blob]', 'Yield']);
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog([
|
|
'actualLoadSpy [[object Blob]]',
|
|
// no onLoadSpy since we have not completed render
|
|
]);
|
|
await waitForAll(['a', 'load triggered', 'last layout', 'last passive']);
|
|
expect(img.__needsDispatch).toBe(true);
|
|
loadImage(img);
|
|
assertLog([
|
|
'actualLoadSpy [[object Blob]]', // the browser reloading of the image causes this to yield again
|
|
'onLoadSpy [[object Blob]]',
|
|
]);
|
|
expect(onLoadSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('captures the load event for Blob sources if it happens after commit phase and replays it', async function () {
|
|
const container = document.createElement('div');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
const blob = new Blob();
|
|
|
|
React.startTransition(() =>
|
|
root.render(
|
|
<PhaseMarkers>
|
|
<Img src={blob} onLoad={onLoadSpy} />
|
|
</PhaseMarkers>,
|
|
),
|
|
);
|
|
|
|
await waitFor([
|
|
'render start',
|
|
'Img [object Blob]',
|
|
'load triggered',
|
|
'last layout',
|
|
]);
|
|
Scheduler.unstable_requestPaint();
|
|
const img = last(images);
|
|
loadImage(img);
|
|
assertLog(['actualLoadSpy [[object Blob]]', 'onLoadSpy [[object Blob]]']);
|
|
await waitForAll(['last passive']);
|
|
expect(img.__needsDispatch).toBe(false);
|
|
expect(onLoadSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|