@samitouri / QOS-React-2 / commits / 086fa8ee2f

re-trigger load events for img elements on commit (#23316)

early load events will be missed by onLoad handlers if they trigger before the tree is committed to avoid this we reset the src property on the img element to cause the browser to re-load the img. Co-authored-by: Josh Story <story@hey.com>

Josh Story committed Feb 28, 2022 at 15:07 UTC 086fa8ee2f80f0dc34b7d145be72f9843fca975d
2 files changed +606 -18
packages/react-dom/src/__tests__/ReactDOMImageLoad-test.internal.js new
+576
@@ -0,0 +1,576 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 Suspense;
17 +let originalCreateElement;
18 +// let TextResource;
19 +// let textResourceShouldFail;
20 +
21 +let images = [];
22 +let onLoadSpy = null;
23 +let actualLoadSpy = null;
24 +
25 +function PhaseMarkers({children}) {
26 + Scheduler.unstable_yieldValue('render start');
27 + React.useLayoutEffect(() => {
28 + Scheduler.unstable_yieldValue('last layout');
29 + });
30 + React.useEffect(() => {
31 + Scheduler.unstable_yieldValue('last passive');
32 + });
33 + return children;
34 +}
35 +
36 +function last(arr) {
37 + if (Array.isArray(arr)) {
38 + if (arr.length) {
39 + return arr[arr.length - 1];
40 + }
41 + return undefined;
42 + }
43 + throw new Error('last was passed something that was not an array');
44 +}
45 +
46 +function Text(props) {
47 + Scheduler.unstable_yieldValue(props.text);
48 + return props.text;
49 +}
50 +
51 +// function AsyncText(props) {
52 +// const text = props.text;
53 +// try {
54 +// TextResource.read([props.text, props.ms]);
55 +// Scheduler.unstable_yieldValue(text);
56 +// return text;
57 +// } catch (promise) {
58 +// if (typeof promise.then === 'function') {
59 +// Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
60 +// } else {
61 +// Scheduler.unstable_yieldValue(`Error! [${text}]`);
62 +// }
63 +// throw promise;
64 +// }
65 +// }
66 +
67 +function Img({src: maybeSrc, onLoad, useImageLoader, ref}) {
68 + const src = maybeSrc || 'default';
69 + Scheduler.unstable_yieldValue('Img ' + src);
70 + return <img src={src} onLoad={onLoad} />;
71 +}
72 +
73 +function Yield() {
74 + Scheduler.unstable_yieldValue('Yield');
75 + Scheduler.unstable_requestPaint();
76 + return null;
77 +}
78 +
79 +function loadImage(element) {
80 + const event = new Event('load');
81 + element.__needsDispatch = false;
82 + element.dispatchEvent(event);
83 +}
84 +
85 +describe('ReactDOMImageLoad', () => {
86 + beforeEach(() => {
87 + jest.resetModules();
88 + React = require('react');
89 + Scheduler = require('scheduler');
90 + // ReactCache = require('react-cache');
91 + ReactDOM = require('react-dom');
92 + // Suspense = React.Suspense;
93 +
94 + onLoadSpy = jest.fn(reactEvent => {
95 + const src = reactEvent.target.getAttribute('src');
96 + Scheduler.unstable_yieldValue('onLoadSpy [' + src + ']');
97 + });
98 +
99 + actualLoadSpy = jest.fn(nativeEvent => {
100 + const src = nativeEvent.target.getAttribute('src');
101 + Scheduler.unstable_yieldValue('actualLoadSpy [' + src + ']');
102 + nativeEvent.__originalDispatch = false;
103 + });
104 +
105 + // TextResource = ReactCache.unstable_createResource(
106 + // ([text, ms = 0]) => {
107 + // let listeners = null;
108 + // let status = 'pending';
109 + // let value = null;
110 + // return {
111 + // then(resolve, reject) {
112 + // switch (status) {
113 + // case 'pending': {
114 + // if (listeners === null) {
115 + // listeners = [{resolve, reject}];
116 + // setTimeout(() => {
117 + // if (textResourceShouldFail) {
118 + // Scheduler.unstable_yieldValue(
119 + // `Promise rejected [${text}]`,
120 + // );
121 + // status = 'rejected';
122 + // value = new Error('Failed to load: ' + text);
123 + // listeners.forEach(listener => listener.reject(value));
124 + // } else {
125 + // Scheduler.unstable_yieldValue(
126 + // `Promise resolved [${text}]`,
127 + // );
128 + // status = 'resolved';
129 + // value = text;
130 + // listeners.forEach(listener => listener.resolve(value));
131 + // }
132 + // }, ms);
133 + // } else {
134 + // listeners.push({resolve, reject});
135 + // }
136 + // break;
137 + // }
138 + // case 'resolved': {
139 + // resolve(value);
140 + // break;
141 + // }
142 + // case 'rejected': {
143 + // reject(value);
144 + // break;
145 + // }
146 + // }
147 + // },
148 + // };
149 + // },
150 + // ([text, ms]) => text,
151 + // );
152 + // textResourceShouldFail = false;
153 +
154 + images = [];
155 +
156 + originalCreateElement = document.createElement;
157 + document.createElement = function createElement(tagName, options) {
158 + const element = originalCreateElement.call(document, tagName, options);
159 + if (tagName === 'img') {
160 + element.addEventListener('load', actualLoadSpy);
161 + images.push(element);
162 + }
163 + return element;
164 + };
165 +
166 + Object.defineProperty(HTMLImageElement.prototype, 'src', {
167 + get() {
168 + return this.getAttribute('src');
169 + },
170 + set(value) {
171 + Scheduler.unstable_yieldValue('load triggered');
172 + this.__needsDispatch = true;
173 + this.setAttribute('src', value);
174 + },
175 + });
176 + });
177 +
178 + afterEach(() => {
179 + document.createElement = originalCreateElement;
180 + });
181 +
182 + it('captures the load event if it happens before commit phase and replays it between layout and passive effects', async function() {
183 + const container = document.createElement('div');
184 + const root = ReactDOM.createRoot(container);
185 +
186 + React.startTransition(() =>
187 + root.render(
188 + <PhaseMarkers>
189 + <Img onLoad={onLoadSpy} />
190 + <Yield />
191 + <Text text={'a'} />
192 + </PhaseMarkers>,
193 + ),
194 + );
195 +
196 + expect(Scheduler).toFlushAndYieldThrough([
197 + 'render start',
198 + 'Img default',
199 + 'Yield',
200 + ]);
201 + const img = last(images);
202 + loadImage(img);
203 + expect(Scheduler).toHaveYielded([
204 + 'actualLoadSpy [default]',
205 + // no onLoadSpy since we have not completed render
206 + ]);
207 + expect(Scheduler).toFlushAndYield([
208 + 'a',
209 + 'load triggered',
210 + 'last layout',
211 + 'last passive',
212 + ]);
213 + expect(img.__needsDispatch).toBe(true);
214 + loadImage(img);
215 + expect(Scheduler).toHaveYielded([
216 + 'actualLoadSpy [default]', // the browser reloading of the image causes this to yield again
217 + 'onLoadSpy [default]',
218 + ]);
219 + expect(onLoadSpy).toHaveBeenCalled();
220 + });
221 +
222 + it('captures the load event if it happens after commit phase and replays it', async function() {
223 + const container = document.createElement('div');
224 + const root = ReactDOM.createRoot(container);
225 +
226 + React.startTransition(() =>
227 + root.render(
228 + <PhaseMarkers>
229 + <Img onLoad={onLoadSpy} />
230 + </PhaseMarkers>,
231 + ),
232 + );
233 +
234 + expect(Scheduler).toFlushAndYieldThrough([
235 + 'render start',
236 + 'Img default',
237 + 'load triggered',
238 + 'last layout',
239 + ]);
240 + Scheduler.unstable_requestPaint();
241 + const img = last(images);
242 + loadImage(img);
243 + expect(Scheduler).toHaveYielded([
244 + 'actualLoadSpy [default]',
245 + 'onLoadSpy [default]',
246 + ]);
247 + expect(Scheduler).toFlushAndYield(['last passive']);
248 + expect(img.__needsDispatch).toBe(false);
249 + expect(onLoadSpy).toHaveBeenCalledTimes(1);
250 + });
251 +
252 + it('it replays the last load event when more than one fire before the end of the layout phase completes', async function() {
253 + const container = document.createElement('div');
254 + const root = ReactDOM.createRoot(container);
255 +
256 + function Base() {
257 + const [src, setSrc] = React.useState('a');
258 + return (
259 + <PhaseMarkers>
260 + <Img src={src} onLoad={onLoadSpy} />
261 + <Yield />
262 + <UpdateSrc setSrc={setSrc} />
263 + </PhaseMarkers>
264 + );
265 + }
266 +
267 + function UpdateSrc({setSrc}) {
268 + React.useLayoutEffect(() => {
269 + setSrc('b');
270 + }, [setSrc]);
271 + return null;
272 + }
273 +
274 + React.startTransition(() => root.render(<Base />));
275 +
276 + expect(Scheduler).toFlushAndYieldThrough([
277 + 'render start',
278 + 'Img a',
279 + 'Yield',
280 + ]);
281 + const img = last(images);
282 + loadImage(img);
283 + expect(Scheduler).toHaveYielded(['actualLoadSpy [a]']);
284 +
285 + expect(Scheduler).toFlushAndYieldThrough([
286 + 'load triggered',
287 + 'last layout',
288 + // the update in layout causes a passive effects flush before a sync render
289 + 'last passive',
290 + 'render start',
291 + 'Img b',
292 + 'Yield',
293 + // yield is ignored becasue we are sync rendering
294 + 'last layout',
295 + 'last passive',
296 + ]);
297 + expect(images.length).toBe(1);
298 + loadImage(img);
299 + expect(Scheduler).toHaveYielded(['actualLoadSpy [b]', 'onLoadSpy [b]']);
300 + expect(onLoadSpy).toHaveBeenCalledTimes(1);
301 + });
302 +
303 + it('replays load events that happen in passive phase after the passive phase.', async function() {
304 + const container = document.createElement('div');
305 + const root = ReactDOM.createRoot(container);
306 +
307 + root.render(
308 + <PhaseMarkers>
309 + <Img onLoad={onLoadSpy} />
310 + </PhaseMarkers>,
311 + );
312 +
313 + expect(Scheduler).toFlushAndYield([
314 + 'render start',
315 + 'Img default',
316 + 'load triggered',
317 + 'last layout',
318 + 'last passive',
319 + ]);
320 + const img = last(images);
321 + loadImage(img);
322 + expect(Scheduler).toHaveYielded([
323 + 'actualLoadSpy [default]',
324 + 'onLoadSpy [default]',
325 + ]);
326 + expect(onLoadSpy).toHaveBeenCalledTimes(1);
327 + });
328 +
329 + 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() {
330 + const container = document.createElement('div');
331 + const root = ReactDOM.createRoot(container);
332 +
333 + function ChildSuppressing({children}) {
334 + const [showChildren, update] = React.useState(true);
335 + React.useLayoutEffect(() => {
336 + if (showChildren) {
337 + update(false);
338 + }
339 + }, [showChildren]);
340 + return showChildren ? children : null;
341 + }
342 +
343 + React.startTransition(() =>
344 + root.render(
345 + <PhaseMarkers>
346 + <ChildSuppressing>
347 + <Img onLoad={onLoadSpy} />
348 + <Yield />
349 + <Text text={'a'} />
350 + </ChildSuppressing>
351 + </PhaseMarkers>,
352 + ),
353 + );
354 +
355 + expect(Scheduler).toFlushAndYieldThrough([
356 + 'render start',
357 + 'Img default',
358 + 'Yield',
359 + ]);
360 + const img = last(images);
361 + loadImage(img);
362 + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
363 + expect(Scheduler).toFlushAndYield([
364 + 'a',
365 + 'load triggered',
366 + 'last layout',
367 + 'last passive',
368 + ]);
369 + expect(img.__needsDispatch).toBe(true);
370 + loadImage(img);
371 + // we expect the browser to load the image again but since we are no longer rendering
372 + // the img there will be no onLoad called
373 + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
374 + expect(Scheduler).toFlushWithoutYielding();
375 + expect(onLoadSpy).not.toHaveBeenCalled();
376 + });
377 +
378 + 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() {
379 + const container = document.createElement('div');
380 + const root = ReactDOM.createRoot(container);
381 +
382 + function Switch({children}) {
383 + const [shouldShow, updateShow] = React.useState(true);
384 + return children(shouldShow, updateShow);
385 + }
386 +
387 + function UpdateSwitchInLayout({updateShow}) {
388 + React.useLayoutEffect(() => {
389 + updateShow(false);
390 + }, []);
391 + return null;
392 + }
393 +
394 + React.startTransition(() =>
395 + root.render(
396 + <Switch>
397 + {(shouldShow, updateShow) => (
398 + <PhaseMarkers>
399 + <>
400 + {shouldShow === true ? (
401 + <>
402 + <Img onLoad={onLoadSpy} />
403 + <Yield />
404 + <Text text={'a'} />
405 + </>
406 + ) : null}
407 + ,
408 + <UpdateSwitchInLayout updateShow={updateShow} />
409 + </>
410 + </PhaseMarkers>
411 + )}
412 + </Switch>,
413 + ),
414 + );
415 +
416 + expect(Scheduler).toFlushAndYieldThrough([
417 + // initial render
418 + 'render start',
419 + 'Img default',
420 + 'Yield',
421 + ]);
422 + const img = last(images);
423 + loadImage(img);
424 + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
425 + expect(Scheduler).toFlushAndYield([
426 + 'a',
427 + 'load triggered',
428 + // img is present at first
429 + 'last layout',
430 + 'last passive',
431 + // sync re-render where the img is suppressed
432 + 'render start',
433 + 'last layout',
434 + 'last passive',
435 + ]);
436 + expect(img.__needsDispatch).toBe(true);
437 + loadImage(img);
438 + // we expect the browser to load the image again but since we are no longer rendering
439 + // the img there will be no onLoad called
440 + expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
441 + expect(Scheduler).toFlushWithoutYielding();
442 + expect(onLoadSpy).not.toHaveBeenCalled();
443 + });
444 +
445 + // it('captures the load event if it happens in a suspended subtree and replays it between layout and passive effects on resumption', async function() {
446 + // function SuspendingWithImage() {
447 + // Scheduler.unstable_yieldValue('SuspendingWithImage');
448 + // return (
449 + // <Suspense fallback={<Text text="Loading..." />}>
450 + // <AsyncText text="A" ms={100} />
451 + // <PhaseMarkers>
452 + // <Img onLoad={onLoadSpy} />
453 + // </PhaseMarkers>
454 + // </Suspense>
455 + // );
456 + // }
457 +
458 + // const container = document.createElement('div');
459 + // const root = ReactDOM.createRoot(container);
460 +
461 + // React.startTransition(() => root.render(<SuspendingWithImage />));
462 +
463 + // expect(Scheduler).toFlushAndYield([
464 + // 'SuspendingWithImage',
465 + // 'Suspend! [A]',
466 + // 'render start',
467 + // 'Img default',
468 + // 'Loading...',
469 + // ]);
470 + // let img = last(images);
471 + // loadImage(img);
472 + // expect(Scheduler).toHaveYielded(['actualLoadSpy [default]']);
473 + // expect(onLoadSpy).not.toHaveBeenCalled();
474 +
475 + // // Flush some of the time
476 + // jest.advanceTimersByTime(50);
477 + // // Still nothing...
478 + // expect(Scheduler).toFlushWithoutYielding();
479 +
480 + // // Flush the promise completely
481 + // jest.advanceTimersByTime(50);
482 + // // Renders successfully
483 + // expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
484 +
485 + // expect(Scheduler).toFlushAndYieldThrough([
486 + // 'A',
487 + // // img was recreated on unsuspended tree causing new load event
488 + // 'render start',
489 + // 'Img default',
490 + // 'last layout',
491 + // ]);
492 +
493 + // expect(images.length).toBe(2);
494 + // img = last(images);
495 + // expect(img.__needsDispatch).toBe(true);
496 + // loadImage(img);
497 + // expect(Scheduler).toHaveYielded([
498 + // 'actualLoadSpy [default]',
499 + // 'onLoadSpy [default]',
500 + // ]);
501 +
502 + // expect(Scheduler).toFlushAndYield(['last passive']);
503 +
504 + // expect(onLoadSpy).toHaveBeenCalledTimes(1);
505 + // });
506 +
507 + it('correctly replays the last img load even when a yield + update causes the host element to change', async function() {
508 + let externalSetSrc = null;
509 + let externalSetSrcAlt = null;
510 +
511 + function Base() {
512 + const [src, setSrc] = React.useState(null);
513 + const [srcAlt, setSrcAlt] = React.useState(null);
514 + externalSetSrc = setSrc;
515 + externalSetSrcAlt = setSrcAlt;
516 + return srcAlt || src ? <YieldingWithImage src={srcAlt || src} /> : null;
517 + }
518 +
519 + function YieldingWithImage({src}) {
520 + Scheduler.unstable_yieldValue('YieldingWithImage');
521 + React.useEffect(() => {
522 + Scheduler.unstable_yieldValue('Committed');
523 + });
524 + return (
525 + <>
526 + <Img src={src} onLoad={onLoadSpy} />
527 + <Yield />
528 + <Text text={src} />
529 + </>
530 + );
531 + }
532 +
533 + const container = document.createElement('div');
534 + const root = ReactDOM.createRoot(container);
535 +
536 + root.render(<Base />);
537 +
538 + expect(Scheduler).toFlushWithoutYielding();
539 +
540 + React.startTransition(() => externalSetSrc('a'));
541 +
542 + expect(Scheduler).toFlushAndYieldThrough([
543 + 'YieldingWithImage',
544 + 'Img a',
545 + 'Yield',
546 + ]);
547 + let img = last(images);
548 + loadImage(img);
549 + expect(Scheduler).toHaveYielded(['actualLoadSpy [a]']);
550 +
551 + ReactDOM.flushSync(() => externalSetSrcAlt('b'));
552 +
553 + expect(Scheduler).toHaveYielded([
554 + 'YieldingWithImage',
555 + 'Img b',
556 + 'Yield',
557 + 'b',
558 + 'load triggered',
559 + 'Committed',
560 + ]);
561 + expect(images.length).toBe(2);
562 + img = last(images);
563 + expect(img.__needsDispatch).toBe(true);
564 + loadImage(img);
565 +
566 + expect(Scheduler).toHaveYielded(['actualLoadSpy [b]', 'onLoadSpy [b]']);
567 + // why is there another update here?
568 + expect(Scheduler).toFlushAndYield([
569 + 'YieldingWithImage',
570 + 'Img b',
571 + 'Yield',
572 + 'b',
573 + 'Committed',
574 + ]);
575 + });
576 +});
packages/react-dom/src/client/ReactDOMHostConfig.js
+30 -18
@@ -146,17 +146,6 @@ const STYLE = 'style';
146 let eventsEnabled: ?boolean = null;
147 let selectionInformation: null | SelectionInformation = null;
148
149 -function shouldAutoFocusHostComponent(type: string, props: Props): boolean {
150 - switch (type) {
151 - case 'button':
152 - case 'input':
153 - case 'select':
154 - case 'textarea':
155 - return !!props.autoFocus;
156 - }
157 - return false;
158 -}
159 -
149 export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence';
150
151 export function getRootHostContext(
@@ -307,7 +296,17 @@ export function finalizeInitialChildren(
296 hostContext: HostContext,
297 ): boolean {
298 setInitialProperties(domElement, type, props, rootContainerInstance);
310 - return shouldAutoFocusHostComponent(type, props);
299 + switch (type) {
300 + case 'button':
301 + case 'input':
302 + case 'select':
303 + case 'textarea':
304 + return !!props.autoFocus;
305 + case 'img':
306 + return true;
307 + default:
308 + return false;
309 + }
310 }
311
312 export function prepareUpdate(
@@ -428,12 +427,25 @@ export function commitMount(
427 // does to implement the `autoFocus` attribute on the client). But
428 // there are also other cases when this might happen (such as patching
429 // up text content during hydration mismatch). So we'll check this again.
431 - if (shouldAutoFocusHostComponent(type, newProps)) {
432 - ((domElement: any):
433 - | HTMLButtonElement
434 - | HTMLInputElement
435 - | HTMLSelectElement
436 - | HTMLTextAreaElement).focus();
430 + switch (type) {
431 + case 'button':
432 + case 'input':
433 + case 'select':
434 + case 'textarea':
435 + if (newProps.autoFocus) {
436 + ((domElement: any):
437 + | HTMLButtonElement
438 + | HTMLInputElement
439 + | HTMLSelectElement
440 + | HTMLTextAreaElement).focus();
441 + }
442 + return;
443 + case 'img': {
444 + if ((newProps: any).src) {
445 + ((domElement: any): HTMLImageElement).src = ((domElement: any): HTMLImageElement).src;
446 + }
447 + return;
448 + }
449 }
450 }
451