main
js 396 lines 11.9 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
14 let ReactDOM;
15 let ReactDOMClient;
16 let Scheduler;
17 let act;
18 let assertLog;
19 let waitFor;
20
21 describe('ReactDOMNativeEventHeuristic-test', () => {
22 let container;
23
24 beforeEach(() => {
25 jest.resetModules();
26 container = document.createElement('div');
27 React = require('react');
28 ReactDOM = require('react-dom');
29 ReactDOMClient = require('react-dom/client');
30 Scheduler = require('scheduler');
31 act = require('internal-test-utils').act;
32
33 const InternalTestUtils = require('internal-test-utils');
34 assertLog = InternalTestUtils.assertLog;
35 waitFor = InternalTestUtils.waitFor;
36
37 document.body.appendChild(container);
38 });
39
40 afterEach(() => {
41 document.body.removeChild(container);
42 });
43
44 function dispatchAndSetCurrentEvent(el, event) {
45 try {
46 window.event = event;
47 el.dispatchEvent(event);
48 } finally {
49 window.event = undefined;
50 }
51 }
52
53 it('ignores discrete events on a pending removed element', async () => {
54 const disableButtonRef = React.createRef();
55 const submitButtonRef = React.createRef();
56
57 function Form() {
58 const [active, setActive] = React.useState(true);
59
60 React.useLayoutEffect(() => {
61 disableButtonRef.current.onclick = disableForm;
62 });
63
64 function disableForm() {
65 setActive(false);
66 }
67
68 return (
69 <div>
70 <button ref={disableButtonRef}>Disable</button>
71 {active ? <button ref={submitButtonRef}>Submit</button> : null}
72 </div>
73 );
74 }
75
76 const root = ReactDOMClient.createRoot(container);
77 await act(() => {
78 root.render(<Form />);
79 });
80
81 const disableButton = disableButtonRef.current;
82 expect(disableButton.tagName).toBe('BUTTON');
83
84 // Dispatch a click event on the Disable-button.
85 await act(async () => {
86 const firstEvent = document.createEvent('Event');
87 firstEvent.initEvent('click', true, true);
88 dispatchAndSetCurrentEvent(disableButton, firstEvent);
89 });
90 // Discrete events should be flushed in a microtask.
91 // Verify that the second button was removed.
92 expect(submitButtonRef.current).toBe(null);
93 // We'll assume that the browser won't let the user click it.
94 });
95
96 it('ignores discrete events on a pending removed event listener', async () => {
97 const disableButtonRef = React.createRef();
98 const submitButtonRef = React.createRef();
99
100 let formSubmitted = false;
101
102 function Form() {
103 const [active, setActive] = React.useState(true);
104
105 React.useLayoutEffect(() => {
106 disableButtonRef.current.onclick = disableForm;
107 submitButtonRef.current.onclick = active
108 ? submitForm
109 : disabledSubmitForm;
110 });
111
112 function disableForm() {
113 setActive(false);
114 }
115
116 function submitForm() {
117 formSubmitted = true; // This should not get invoked
118 }
119
120 function disabledSubmitForm() {
121 // The form is disabled.
122 }
123
124 return (
125 <div>
126 <button ref={disableButtonRef}>Disable</button>
127 <button ref={submitButtonRef}>Submit</button>
128 </div>
129 );
130 }
131
132 const root = ReactDOMClient.createRoot(container);
133 // Flush
134 await act(() => root.render(<Form />));
135
136 const disableButton = disableButtonRef.current;
137 expect(disableButton.tagName).toBe('BUTTON');
138
139 // Dispatch a click event on the Disable-button.
140 const firstEvent = document.createEvent('Event');
141 firstEvent.initEvent('click', true, true);
142 await act(() => {
143 dispatchAndSetCurrentEvent(disableButton, firstEvent);
144
145 // There should now be a pending update to disable the form.
146 // This should not have flushed yet since it's in concurrent mode.
147 const submitButton = submitButtonRef.current;
148 expect(submitButton.tagName).toBe('BUTTON');
149
150 // Flush the discrete event
151 ReactDOM.flushSync();
152
153 // Now let's dispatch an event on the submit button.
154 const secondEvent = document.createEvent('Event');
155 secondEvent.initEvent('click', true, true);
156 dispatchAndSetCurrentEvent(submitButton, secondEvent);
157 });
158
159 // Therefore the form should never have been submitted.
160 expect(formSubmitted).toBe(false);
161 });
162
163 it('uses the newest discrete events on a pending changed event listener', async () => {
164 const enableButtonRef = React.createRef();
165 const submitButtonRef = React.createRef();
166
167 let formSubmitted = false;
168
169 function Form() {
170 const [active, setActive] = React.useState(false);
171
172 React.useLayoutEffect(() => {
173 enableButtonRef.current.onclick = enableForm;
174 submitButtonRef.current.onclick = active ? submitForm : null;
175 });
176
177 function enableForm() {
178 setActive(true);
179 }
180
181 function submitForm() {
182 formSubmitted = true; // This should not get invoked
183 }
184
185 return (
186 <div>
187 <button ref={enableButtonRef}>Enable</button>
188 <button ref={submitButtonRef}>Submit</button>
189 </div>
190 );
191 }
192
193 const root = ReactDOMClient.createRoot(container);
194 await act(() => root.render(<Form />));
195
196 const enableButton = enableButtonRef.current;
197 expect(enableButton.tagName).toBe('BUTTON');
198
199 // Dispatch a click event on the Enable-button.
200 await act(() => {
201 const firstEvent = document.createEvent('Event');
202 firstEvent.initEvent('click', true, true);
203 dispatchAndSetCurrentEvent(enableButton, firstEvent);
204
205 // There should now be a pending update to enable the form.
206 // This should not have flushed yet since it's in concurrent mode.
207 const submitButton = submitButtonRef.current;
208 expect(submitButton.tagName).toBe('BUTTON');
209
210 // Flush discrete updates
211 ReactDOM.flushSync();
212
213 // Now let's dispatch an event on the submit button.
214 const secondEvent = document.createEvent('Event');
215 secondEvent.initEvent('click', true, true);
216 dispatchAndSetCurrentEvent(submitButton, secondEvent);
217 });
218
219 // Therefore the form should have been submitted.
220 expect(formSubmitted).toBe(true);
221 });
222
223 it('mouse over should be user-blocking but not discrete', async () => {
224 const root = ReactDOMClient.createRoot(container);
225
226 const target = React.createRef(null);
227 function Foo() {
228 const [isHover, setHover] = React.useState(false);
229 React.useLayoutEffect(() => {
230 target.current.onmouseover = () => setHover(true);
231 });
232 return <div ref={target}>{isHover ? 'hovered' : 'not hovered'}</div>;
233 }
234
235 await act(() => {
236 root.render(<Foo />);
237 });
238 expect(container.textContent).toEqual('not hovered');
239
240 await act(() => {
241 const mouseOverEvent = document.createEvent('MouseEvents');
242 mouseOverEvent.initEvent('mouseover', true, true);
243 dispatchAndSetCurrentEvent(target.current, mouseOverEvent);
244
245 // Flush discrete updates
246 ReactDOM.flushSync();
247 // Since mouse over is not discrete, should not have updated yet
248 expect(container.textContent).toEqual('not hovered');
249 });
250 expect(container.textContent).toEqual('hovered');
251 });
252
253 it('mouse enter should be user-blocking but not discrete', async () => {
254 const root = ReactDOMClient.createRoot(container);
255
256 const target = React.createRef(null);
257 function Foo() {
258 const [isHover, setHover] = React.useState(false);
259 React.useLayoutEffect(() => {
260 target.current.onmouseenter = () => setHover(true);
261 });
262 return <div ref={target}>{isHover ? 'hovered' : 'not hovered'}</div>;
263 }
264
265 await act(() => {
266 root.render(<Foo />);
267 });
268 expect(container.textContent).toEqual('not hovered');
269
270 await act(() => {
271 // Note: React does not use native mouseenter/mouseleave events
272 // but we should still correctly determine their priority.
273 const mouseEnterEvent = document.createEvent('MouseEvents');
274 mouseEnterEvent.initEvent('mouseenter', true, true);
275 dispatchAndSetCurrentEvent(target.current, mouseEnterEvent);
276
277 // Flush discrete updates
278 ReactDOM.flushSync();
279 // Since mouse end is not discrete, should not have updated yet
280 expect(container.textContent).toEqual('not hovered');
281 });
282 expect(container.textContent).toEqual('hovered');
283 });
284
285 it('continuous native events flush as expected', async () => {
286 const root = ReactDOMClient.createRoot(container);
287
288 const target = React.createRef(null);
289 function Foo({hovered}) {
290 const hoverString = hovered ? 'hovered' : 'not hovered';
291 Scheduler.log(hoverString);
292 return <div ref={target}>{hoverString}</div>;
293 }
294
295 await act(() => {
296 root.render(<Foo hovered={false} />);
297 });
298 expect(container.textContent).toEqual('not hovered');
299
300 assertLog(['not hovered']);
301 await act(async () => {
302 // Note: React does not use native mouseenter/mouseleave events
303 // but we should still correctly determine their priority.
304 const mouseEnterEvent = document.createEvent('MouseEvents');
305 mouseEnterEvent.initEvent('mouseover', true, true);
306 target.current.addEventListener('mouseover', () => {
307 root.render(<Foo hovered={true} />);
308 });
309 dispatchAndSetCurrentEvent(target.current, mouseEnterEvent);
310
311 // Since mouse end is not discrete, should not have updated yet
312 assertLog([]);
313 expect(container.textContent).toEqual('not hovered');
314
315 await waitFor(['hovered']);
316 expect(container.textContent).toEqual('hovered');
317 });
318 expect(container.textContent).toEqual('hovered');
319 });
320
321 it('should batch inside native events', async () => {
322 const root = ReactDOMClient.createRoot(container);
323
324 const target = React.createRef(null);
325 function Foo() {
326 const [count, setCount] = React.useState(0);
327 const countRef = React.useRef(-1);
328
329 React.useLayoutEffect(() => {
330 countRef.current = count;
331 target.current.onclick = () => {
332 setCount(countRef.current + 1);
333 // Now update again. If these updates are batched, then this should be
334 // a no-op, because we didn't re-render yet and `countRef` hasn't
335 // been mutated.
336 setCount(countRef.current + 1);
337 };
338 });
339 return <div ref={target}>Count: {count}</div>;
340 }
341
342 await act(() => {
343 root.render(<Foo />);
344 });
345 expect(container.textContent).toEqual('Count: 0');
346
347 await act(async () => {
348 const pressEvent = document.createEvent('Event');
349 pressEvent.initEvent('click', true, true);
350 dispatchAndSetCurrentEvent(target.current, pressEvent);
351 });
352 // If this is 2, that means the `setCount` calls were not batched.
353 expect(container.textContent).toEqual('Count: 1');
354 });
355
356 it('should not flush discrete events at the end of outermost batchedUpdates', async () => {
357 const root = ReactDOMClient.createRoot(container);
358
359 let target;
360 function Foo() {
361 const [count, setCount] = React.useState(0);
362 return (
363 <div
364 ref={el => {
365 target = el;
366 if (target !== null) {
367 el.onclick = () => {
368 ReactDOM.unstable_batchedUpdates(() => {
369 setCount(count + 1);
370 });
371 Scheduler.log(
372 container.textContent + ' [after batchedUpdates]',
373 );
374 };
375 }
376 }}>
377 Count: {count}
378 </div>
379 );
380 }
381
382 await act(() => {
383 root.render(<Foo />);
384 });
385 expect(container.textContent).toEqual('Count: 0');
386
387 await act(async () => {
388 const pressEvent = document.createEvent('Event');
389 pressEvent.initEvent('click', true, true);
390 dispatchAndSetCurrentEvent(target, pressEvent);
391 assertLog(['Count: 0 [after batchedUpdates]']);
392 expect(container.textContent).toEqual('Count: 0');
393 });
394 expect(container.textContent).toEqual('Count: 1');
395 });
396 });