diff --git a/packages/react-dom/src/__tests__/ReactDOMImageLoad-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMImageLoad-test.internal.js new file mode 100644 index 0000000000000..bc7316349196a --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMImageLoad-test.internal.js @@ -0,0 +1,576 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 Suspense; +let originalCreateElement; +// let TextResource; +// let textResourceShouldFail; + +let images = []; +let onLoadSpy = null; +let actualLoadSpy = null; + +function PhaseMarkers({children}) { + Scheduler.unstable_yieldValue('render start'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('last layout'); + }); + React.useEffect(() => { + Scheduler.unstable_yieldValue('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.unstable_yieldValue(props.text); + return props.text; +} + +// function AsyncText(props) { +// const text = props.text; +// try { +// TextResource.read([props.text, props.ms]); +// Scheduler.unstable_yieldValue(text); +// return text; +// } catch (promise) { +// if (typeof promise.then === 'function') { +// Scheduler.unstable_yieldValue(`Suspend! [${text}]`); +// } else { +// Scheduler.unstable_yieldValue(`Error! [${text}]`); +// } +// throw promise; +// } +// } + +function Img({src: maybeSrc, onLoad, useImageLoader, ref}) { + const src = maybeSrc || 'default'; + Scheduler.unstable_yieldValue('Img ' + src); + return ; +} + +function Yield() { + Scheduler.unstable_yieldValue('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'); + // Suspense = React.Suspense; + + onLoadSpy = jest.fn(reactEvent => { + const src = reactEvent.target.getAttribute('src'); + Scheduler.unstable_yieldValue('onLoadSpy [' + src + ']'); + }); + + actualLoadSpy = jest.fn(nativeEvent => { + const src = nativeEvent.target.getAttribute('src'); + Scheduler.unstable_yieldValue('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.unstable_yieldValue( + // `Promise rejected [${text}]`, + // ); + // status = 'rejected'; + // value = new Error('Failed to load: ' + text); + // listeners.forEach(listener => listener.reject(value)); + // } else { + // Scheduler.unstable_yieldValue( + // `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; + }; + + Object.defineProperty(HTMLImageElement.prototype, 'src', { + get() { + return this.getAttribute('src'); + }, + set(value) { + Scheduler.unstable_yieldValue('load triggered'); + this.__needsDispatch = true; + this.setAttribute('src', value); + }, + }); + }); + + afterEach(() => { + document.createElement = originalCreateElement; + }); + + 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 = ReactDOM.createRoot(container); + + React.startTransition(() => + root.render( + + + + + , + ), + ); + + expect(Scheduler).toFlushAndYieldThrough([ + 'render start', + 'Img default', + 'Yield', + ]); + const img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded([ + 'actualLoadSpy [default]', + // no onLoadSpy since we have not completed render + ]); + expect(Scheduler).toFlushAndYield([ + 'a', + 'load triggered', + 'last layout', + 'last passive', + ]); + expect(img.__needsDispatch).toBe(true); + loadImage(img); + expect(Scheduler).toHaveYielded([ + '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 = ReactDOM.createRoot(container); + + React.startTransition(() => + root.render( + + + , + ), + ); + + expect(Scheduler).toFlushAndYieldThrough([ + 'render start', + 'Img default', + 'load triggered', + 'last layout', + ]); + Scheduler.unstable_requestPaint(); + const img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded([ + 'actualLoadSpy [default]', + 'onLoadSpy [default]', + ]); + expect(Scheduler).toFlushAndYield(['last passive']); + expect(img.__needsDispatch).toBe(false); + expect(onLoadSpy).toHaveBeenCalledTimes(1); + }); + + it('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 = ReactDOM.createRoot(container); + + function Base() { + const [src, setSrc] = React.useState('a'); + return ( + + + + + + ); + } + + function UpdateSrc({setSrc}) { + React.useLayoutEffect(() => { + setSrc('b'); + }, [setSrc]); + return null; + } + + React.startTransition(() => root.render()); + + expect(Scheduler).toFlushAndYieldThrough([ + 'render start', + 'Img a', + 'Yield', + ]); + const img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded(['actualLoadSpy [a]']); + + expect(Scheduler).toFlushAndYieldThrough([ + '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); + expect(Scheduler).toHaveYielded(['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 = ReactDOM.createRoot(container); + + root.render( + + + , + ); + + expect(Scheduler).toFlushAndYield([ + 'render start', + 'Img default', + 'load triggered', + 'last layout', + 'last passive', + ]); + const img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded([ + '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 = ReactDOM.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( + + + + + + + , + ), + ); + + expect(Scheduler).toFlushAndYieldThrough([ + 'render start', + 'Img default', + 'Yield', + ]); + const img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']); + expect(Scheduler).toFlushAndYield([ + '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 + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']); + expect(Scheduler).toFlushWithoutYielding(); + 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 = ReactDOM.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( + + {(shouldShow, updateShow) => ( + + <> + {shouldShow === true ? ( + <> + + + + + ) : null} + , + + + + )} + , + ), + ); + + expect(Scheduler).toFlushAndYieldThrough([ + // initial render + 'render start', + 'Img default', + 'Yield', + ]); + const img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']); + expect(Scheduler).toFlushAndYield([ + '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 + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']); + expect(Scheduler).toFlushWithoutYielding(); + expect(onLoadSpy).not.toHaveBeenCalled(); + }); + + // 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.unstable_yieldValue('SuspendingWithImage'); + // return ( + // }> + // + // + // + // + // + // ); + // } + + // const container = document.createElement('div'); + // const root = ReactDOM.createRoot(container); + + // React.startTransition(() => root.render()); + + // 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 ? : null; + } + + function YieldingWithImage({src}) { + Scheduler.unstable_yieldValue('YieldingWithImage'); + React.useEffect(() => { + Scheduler.unstable_yieldValue('Committed'); + }); + return ( + <> + + + + + ); + } + + const container = document.createElement('div'); + const root = ReactDOM.createRoot(container); + + root.render(); + + expect(Scheduler).toFlushWithoutYielding(); + + React.startTransition(() => externalSetSrc('a')); + + expect(Scheduler).toFlushAndYieldThrough([ + 'YieldingWithImage', + 'Img a', + 'Yield', + ]); + let img = last(images); + loadImage(img); + expect(Scheduler).toHaveYielded(['actualLoadSpy [a]']); + + ReactDOM.flushSync(() => externalSetSrcAlt('b')); + + expect(Scheduler).toHaveYielded([ + 'YieldingWithImage', + 'Img b', + 'Yield', + 'b', + 'load triggered', + 'Committed', + ]); + expect(images.length).toBe(2); + img = last(images); + expect(img.__needsDispatch).toBe(true); + loadImage(img); + + expect(Scheduler).toHaveYielded(['actualLoadSpy [b]', 'onLoadSpy [b]']); + // why is there another update here? + expect(Scheduler).toFlushAndYield([ + 'YieldingWithImage', + 'Img b', + 'Yield', + 'b', + 'Committed', + ]); + }); +}); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 97eb494ecba2a..fbd04da485fe3 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -146,17 +146,6 @@ const STYLE = 'style'; let eventsEnabled: ?boolean = null; let selectionInformation: null | SelectionInformation = null; -function shouldAutoFocusHostComponent(type: string, props: Props): boolean { - switch (type) { - case 'button': - case 'input': - case 'select': - case 'textarea': - return !!props.autoFocus; - } - return false; -} - export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence'; export function getRootHostContext( @@ -307,7 +296,17 @@ export function finalizeInitialChildren( hostContext: HostContext, ): boolean { setInitialProperties(domElement, type, props, rootContainerInstance); - return shouldAutoFocusHostComponent(type, props); + switch (type) { + case 'button': + case 'input': + case 'select': + case 'textarea': + return !!props.autoFocus; + case 'img': + return true; + default: + return false; + } } export function prepareUpdate( @@ -428,12 +427,25 @@ export function commitMount( // does to implement the `autoFocus` attribute on the client). But // there are also other cases when this might happen (such as patching // up text content during hydration mismatch). So we'll check this again. - if (shouldAutoFocusHostComponent(type, newProps)) { - ((domElement: any): - | HTMLButtonElement - | HTMLInputElement - | HTMLSelectElement - | HTMLTextAreaElement).focus(); + switch (type) { + case 'button': + case 'input': + case 'select': + case 'textarea': + if (newProps.autoFocus) { + ((domElement: any): + | HTMLButtonElement + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement).focus(); + } + return; + case 'img': { + if ((newProps: any).src) { + ((domElement: any): HTMLImageElement).src = ((domElement: any): HTMLImageElement).src; + } + return; + } } }