main
js 907 lines 26.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 waitForAll;
19 let waitFor;
20 let waitForMicrotasks;
21 let assertLog;
22 let assertConsoleErrorDev;
23
24 const setUntrackedInputValue = Object.getOwnPropertyDescriptor(
25 HTMLInputElement.prototype,
26 'value',
27 ).set;
28
29 describe('ReactDOMFiberAsync', () => {
30 let container;
31
32 beforeEach(() => {
33 container = document.createElement('div');
34 React = require('react');
35 ReactDOM = require('react-dom');
36 ReactDOMClient = require('react-dom/client');
37 act = require('internal-test-utils').act;
38 assertConsoleErrorDev =
39 require('internal-test-utils').assertConsoleErrorDev;
40 Scheduler = require('scheduler');
41
42 const InternalTestUtils = require('internal-test-utils');
43 waitForAll = InternalTestUtils.waitForAll;
44 waitFor = InternalTestUtils.waitFor;
45 waitForMicrotasks = InternalTestUtils.waitForMicrotasks;
46 assertLog = InternalTestUtils.assertLog;
47
48 document.body.appendChild(container);
49 window.event = undefined;
50 });
51
52 afterEach(() => {
53 document.body.removeChild(container);
54 });
55
56 // @gate !disableLegacyMode
57 it('renders synchronously by default in legacy mode', () => {
58 const ops = [];
59 ReactDOM.render(<div>Hi</div>, container, () => {
60 ops.push(container.textContent);
61 });
62 ReactDOM.render(<div>Bye</div>, container, () => {
63 ops.push(container.textContent);
64 });
65 expect(ops).toEqual(['Hi', 'Bye']);
66 });
67
68 it('flushSync batches sync updates and flushes them at the end of the batch', async () => {
69 const ops = [];
70 let instance;
71
72 class Component extends React.Component {
73 state = {text: ''};
74 componentDidMount() {
75 instance = this;
76 }
77
78 push(val) {
79 this.setState(state => ({text: state.text + val}));
80 }
81 componentDidUpdate() {
82 ops.push(this.state.text);
83 }
84 render() {
85 instance = this;
86 return <span>{this.state.text}</span>;
87 }
88 }
89
90 const root = ReactDOMClient.createRoot(container);
91 await act(() => root.render(<Component />));
92
93 await act(() => {
94 instance.push('A');
95 });
96
97 expect(ops).toEqual(['A']);
98 expect(container.textContent).toEqual('A');
99
100 ReactDOM.flushSync(() => {
101 instance.push('B');
102 instance.push('C');
103 // Not flushed yet
104 expect(container.textContent).toEqual('A');
105 expect(ops).toEqual(['A']);
106 });
107
108 expect(container.textContent).toEqual('ABC');
109 expect(ops).toEqual(['A', 'ABC']);
110 await act(() => {
111 instance.push('D');
112 });
113 expect(container.textContent).toEqual('ABCD');
114 expect(ops).toEqual(['A', 'ABC', 'ABCD']);
115 });
116
117 it('flushSync flushes updates even if nested inside another flushSync', async () => {
118 const ops = [];
119 let instance;
120
121 class Component extends React.Component {
122 state = {text: ''};
123 componentDidMount() {
124 instance = this;
125 }
126
127 push(val) {
128 this.setState(state => ({text: state.text + val}));
129 }
130 componentDidUpdate() {
131 ops.push(this.state.text);
132 }
133 render() {
134 instance = this;
135 return <span>{this.state.text}</span>;
136 }
137 }
138
139 const root = ReactDOMClient.createRoot(container);
140 await act(() => root.render(<Component />));
141
142 await act(() => {
143 instance.push('A');
144 });
145 expect(ops).toEqual(['A']);
146 expect(container.textContent).toEqual('A');
147
148 ReactDOM.flushSync(() => {
149 instance.push('B');
150 instance.push('C');
151 // Not flushed yet
152 expect(container.textContent).toEqual('A');
153 expect(ops).toEqual(['A']);
154
155 ReactDOM.flushSync(() => {
156 instance.push('D');
157 });
158 // The nested flushSync caused everything to flush.
159 expect(container.textContent).toEqual('ABCD');
160 expect(ops).toEqual(['A', 'ABCD']);
161 });
162 expect(container.textContent).toEqual('ABCD');
163 expect(ops).toEqual(['A', 'ABCD']);
164 });
165
166 it('flushSync logs an error if already performing work', async () => {
167 class Component extends React.Component {
168 componentDidUpdate() {
169 ReactDOM.flushSync();
170 }
171 render() {
172 return null;
173 }
174 }
175
176 // Initial mount
177 const root = ReactDOMClient.createRoot(container);
178 await act(() => {
179 root.render(<Component />);
180 });
181 // Update
182 ReactDOM.flushSync(() => {
183 root.render(<Component />);
184 });
185 assertConsoleErrorDev([
186 'flushSync was called from inside a lifecycle method. ' +
187 'React cannot flush when React is already rendering. ' +
188 'Consider moving this call to a scheduler task or micro task.\n' +
189 ' in Component (at **)',
190 ]);
191 });
192
193 describe('concurrent mode', () => {
194 it('does not perform deferred updates synchronously', async () => {
195 const inputRef = React.createRef();
196 const asyncValueRef = React.createRef();
197 const syncValueRef = React.createRef();
198
199 class Counter extends React.Component {
200 state = {asyncValue: '', syncValue: ''};
201
202 handleChange = e => {
203 const nextValue = e.target.value;
204 React.startTransition(() => {
205 this.setState({
206 asyncValue: nextValue,
207 });
208 // It should not be flushed yet.
209 expect(asyncValueRef.current.textContent).toBe('');
210 });
211 this.setState({
212 syncValue: nextValue,
213 });
214 };
215
216 render() {
217 return (
218 <div>
219 <input
220 ref={inputRef}
221 onChange={this.handleChange}
222 defaultValue=""
223 />
224 <p ref={asyncValueRef}>{this.state.asyncValue}</p>
225 <p ref={syncValueRef}>{this.state.syncValue}</p>
226 </div>
227 );
228 }
229 }
230 const root = ReactDOMClient.createRoot(container);
231 await act(() => root.render(<Counter />));
232 expect(asyncValueRef.current.textContent).toBe('');
233 expect(syncValueRef.current.textContent).toBe('');
234
235 await act(() => {
236 setUntrackedInputValue.call(inputRef.current, 'hello');
237 inputRef.current.dispatchEvent(
238 new MouseEvent('input', {bubbles: true}),
239 );
240 // Should only flush non-deferred update.
241 expect(asyncValueRef.current.textContent).toBe('');
242 expect(syncValueRef.current.textContent).toBe('hello');
243 });
244
245 // Should flush both updates now.
246 expect(asyncValueRef.current.textContent).toBe('hello');
247 expect(syncValueRef.current.textContent).toBe('hello');
248 });
249
250 it('top-level updates are concurrent', async () => {
251 const root = ReactDOMClient.createRoot(container);
252 await act(() => {
253 root.render(<div>Hi</div>);
254 expect(container.textContent).toEqual('');
255 });
256 expect(container.textContent).toEqual('Hi');
257
258 await act(() => {
259 root.render(<div>Bye</div>);
260 expect(container.textContent).toEqual('Hi');
261 });
262 expect(container.textContent).toEqual('Bye');
263 });
264
265 it('deep updates (setState) are concurrent', async () => {
266 let instance;
267 class Component extends React.Component {
268 state = {step: 0};
269 render() {
270 instance = this;
271 return <div>{this.state.step}</div>;
272 }
273 }
274
275 const root = ReactDOMClient.createRoot(container);
276
277 await act(() => {
278 root.render(<Component />);
279 expect(container.textContent).toEqual('');
280 });
281 expect(container.textContent).toEqual('0');
282
283 await act(() => {
284 instance.setState({step: 1});
285 expect(container.textContent).toEqual('0');
286 });
287 expect(container.textContent).toEqual('1');
288 });
289
290 it('flushSync flushes updates before end of the tick', async () => {
291 let instance;
292
293 class Component extends React.Component {
294 state = {text: ''};
295 push(val) {
296 this.setState(state => ({text: state.text + val}));
297 }
298 componentDidUpdate() {
299 Scheduler.log(this.state.text);
300 }
301 render() {
302 instance = this;
303 return <span>{this.state.text}</span>;
304 }
305 }
306
307 const root = ReactDOMClient.createRoot(container);
308 await act(() => root.render(<Component />));
309
310 // Updates are async by default
311 instance.push('A');
312 assertLog([]);
313 expect(container.textContent).toEqual('');
314
315 ReactDOM.flushSync(() => {
316 instance.push('B');
317 instance.push('C');
318 // Not flushed yet
319 expect(container.textContent).toEqual('');
320 assertLog([]);
321 });
322 // Only the active updates have flushed
323 expect(container.textContent).toEqual('ABC');
324 assertLog(['ABC']);
325
326 await act(() => {
327 instance.push('D');
328 expect(container.textContent).toEqual('ABC');
329 assertLog([]);
330 });
331 assertLog(['ABCD']);
332 expect(container.textContent).toEqual('ABCD');
333 });
334
335 it('ignores discrete events on a pending removed element', async () => {
336 const disableButtonRef = React.createRef();
337 const submitButtonRef = React.createRef();
338
339 function Form() {
340 const [active, setActive] = React.useState(true);
341 function disableForm() {
342 setActive(false);
343 }
344
345 return (
346 <div>
347 <button onClick={disableForm} ref={disableButtonRef}>
348 Disable
349 </button>
350 {active ? <button ref={submitButtonRef}>Submit</button> : null}
351 </div>
352 );
353 }
354
355 const root = ReactDOMClient.createRoot(container);
356 await act(() => {
357 root.render(<Form />);
358 });
359
360 const disableButton = disableButtonRef.current;
361 expect(disableButton.tagName).toBe('BUTTON');
362
363 const submitButton = submitButtonRef.current;
364 expect(submitButton.tagName).toBe('BUTTON');
365
366 // Dispatch a click event on the Disable-button.
367 const firstEvent = document.createEvent('Event');
368 firstEvent.initEvent('click', true, true);
369 disableButton.dispatchEvent(firstEvent);
370
371 // The click event is flushed synchronously, even in concurrent mode.
372 expect(submitButton.current).toBe(undefined);
373 });
374
375 it('ignores discrete events on a pending removed event listener', async () => {
376 const disableButtonRef = React.createRef();
377 const submitButtonRef = React.createRef();
378
379 let formSubmitted = false;
380
381 function Form() {
382 const [active, setActive] = React.useState(true);
383 function disableForm() {
384 setActive(false);
385 }
386 function submitForm() {
387 formSubmitted = true; // This should not get invoked
388 }
389 function disabledSubmitForm() {
390 // The form is disabled.
391 }
392 return (
393 <div>
394 <button onClick={disableForm} ref={disableButtonRef}>
395 Disable
396 </button>
397 <button
398 onClick={active ? submitForm : disabledSubmitForm}
399 ref={submitButtonRef}>
400 Submit
401 </button>
402 </div>
403 );
404 }
405
406 const root = ReactDOMClient.createRoot(container);
407 await act(() => {
408 root.render(<Form />);
409 });
410
411 const disableButton = disableButtonRef.current;
412 expect(disableButton.tagName).toBe('BUTTON');
413
414 // Dispatch a click event on the Disable-button.
415 const firstEvent = document.createEvent('Event');
416 firstEvent.initEvent('click', true, true);
417 await act(() => {
418 disableButton.dispatchEvent(firstEvent);
419 });
420
421 // There should now be a pending update to disable the form.
422
423 // This should not have flushed yet since it's in concurrent mode.
424 const submitButton = submitButtonRef.current;
425 expect(submitButton.tagName).toBe('BUTTON');
426
427 // In the meantime, we can dispatch a new client event on the submit button.
428 const secondEvent = document.createEvent('Event');
429 secondEvent.initEvent('click', true, true);
430 // This should force the pending update to flush which disables the submit button before the event is invoked.
431 await act(() => {
432 submitButton.dispatchEvent(secondEvent);
433 });
434
435 // Therefore the form should never have been submitted.
436 expect(formSubmitted).toBe(false);
437 });
438
439 it('uses the newest discrete events on a pending changed event listener', async () => {
440 const enableButtonRef = React.createRef();
441 const submitButtonRef = React.createRef();
442
443 let formSubmitted = false;
444
445 function Form() {
446 const [active, setActive] = React.useState(false);
447 function enableForm() {
448 setActive(true);
449 }
450 function submitForm() {
451 formSubmitted = true; // This should not get invoked
452 }
453 return (
454 <div>
455 <button onClick={enableForm} ref={enableButtonRef}>
456 Enable
457 </button>
458 <button onClick={active ? submitForm : null} ref={submitButtonRef}>
459 Submit
460 </button>
461 </div>
462 );
463 }
464
465 const root = ReactDOMClient.createRoot(container);
466 await act(() => {
467 root.render(<Form />);
468 });
469
470 const enableButton = enableButtonRef.current;
471 expect(enableButton.tagName).toBe('BUTTON');
472
473 // Dispatch a click event on the Enable-button.
474 const firstEvent = document.createEvent('Event');
475 firstEvent.initEvent('click', true, true);
476 await act(() => {
477 enableButton.dispatchEvent(firstEvent);
478 });
479
480 // There should now be a pending update to enable the form.
481
482 // This should not have flushed yet since it's in concurrent mode.
483 const submitButton = submitButtonRef.current;
484 expect(submitButton.tagName).toBe('BUTTON');
485
486 // In the meantime, we can dispatch a new client event on the submit button.
487 const secondEvent = document.createEvent('Event');
488 secondEvent.initEvent('click', true, true);
489 // This should force the pending update to flush which enables the submit button before the event is invoked.
490 await act(() => {
491 submitButton.dispatchEvent(secondEvent);
492 });
493
494 // Therefore the form should have been submitted.
495 expect(formSubmitted).toBe(true);
496 });
497 });
498
499 it('regression test: does not drop passive effects across roots (#17066)', async () => {
500 const {useState, useEffect} = React;
501
502 function App({label}) {
503 const [step, setStep] = useState(0);
504 useEffect(() => {
505 if (step < 3) {
506 setStep(step + 1);
507 }
508 }, [step]);
509
510 // The component should keep re-rendering itself until `step` is 3.
511 return step === 3 ? 'Finished' : 'Unresolved';
512 }
513
514 const containerA = document.createElement('div');
515 const containerB = document.createElement('div');
516 const containerC = document.createElement('div');
517 const rootA = ReactDOMClient.createRoot(containerA);
518 const rootB = ReactDOMClient.createRoot(containerB);
519 const rootC = ReactDOMClient.createRoot(containerC);
520
521 await act(() => {
522 rootA.render(<App label="A" />);
523 rootB.render(<App label="B" />);
524 rootC.render(<App label="C" />);
525 });
526
527 expect(containerA.textContent).toEqual('Finished');
528 expect(containerB.textContent).toEqual('Finished');
529 expect(containerC.textContent).toEqual('Finished');
530 });
531
532 it('updates flush without yielding in the next event', async () => {
533 const root = ReactDOMClient.createRoot(container);
534
535 function Text(props) {
536 Scheduler.log(props.text);
537 return props.text;
538 }
539
540 root.render(
541 <>
542 <Text text="A" />
543 <Text text="B" />
544 <Text text="C" />
545 </>,
546 );
547
548 // Nothing should have rendered yet
549 expect(container.textContent).toEqual('');
550
551 // Everything should render immediately in the next event
552 await waitForAll(['A', 'B', 'C']);
553 expect(container.textContent).toEqual('ABC');
554 });
555
556 it('unmounted roots should never clear newer root content from a container', async () => {
557 const ref = React.createRef();
558
559 function OldApp() {
560 const [value, setValue] = React.useState('old');
561 function hideOnClick() {
562 // Schedule a discrete update.
563 setValue('update');
564 // Synchronously unmount this root.
565 ReactDOM.flushSync(() => oldRoot.unmount());
566 }
567 return (
568 <button onClick={hideOnClick} ref={ref}>
569 {value}
570 </button>
571 );
572 }
573
574 function NewApp() {
575 return <button ref={ref}>new</button>;
576 }
577
578 const oldRoot = ReactDOMClient.createRoot(container);
579 await act(() => {
580 oldRoot.render(<OldApp />);
581 });
582
583 // Invoke discrete event.
584 ref.current.click();
585
586 // The root should now be unmounted.
587 expect(container.textContent).toBe('');
588
589 // We can now render a new one.
590 const newRoot = ReactDOMClient.createRoot(container);
591 ReactDOM.flushSync(() => {
592 newRoot.render(<NewApp />);
593 });
594 ref.current.click();
595
596 expect(container.textContent).toBe('new');
597 });
598
599 it('should synchronously render the transition lane scheduled in a popState', async () => {
600 function App() {
601 const [syncState, setSyncState] = React.useState(false);
602 const [hasNavigated, setHasNavigated] = React.useState(false);
603 function onPopstate() {
604 Scheduler.log(`popState`);
605 React.startTransition(() => {
606 setHasNavigated(true);
607 });
608 setSyncState(true);
609 }
610 React.useEffect(() => {
611 window.addEventListener('popstate', onPopstate);
612 return () => {
613 window.removeEventListener('popstate', onPopstate);
614 };
615 }, []);
616 Scheduler.log(`render:${hasNavigated}/${syncState}`);
617 return null;
618 }
619 const root = ReactDOMClient.createRoot(container);
620 await act(async () => {
621 root.render(<App />);
622 });
623 assertLog(['render:false/false']);
624
625 await act(async () => {
626 const popStateEvent = new Event('popstate');
627 // Jest is not emulating window.event correctly in the microtask
628 window.event = popStateEvent;
629 window.dispatchEvent(popStateEvent);
630 queueMicrotask(() => {
631 window.event = undefined;
632 });
633 });
634
635 assertLog(['popState', 'render:true/true']);
636 await act(() => {
637 root.unmount();
638 });
639 });
640
641 it('Should not flush transition lanes if there is no transition scheduled in popState', async () => {
642 let setHasNavigated;
643 function App() {
644 const [syncState, setSyncState] = React.useState(false);
645 const [hasNavigated, _setHasNavigated] = React.useState(false);
646 setHasNavigated = _setHasNavigated;
647 function onPopstate() {
648 setSyncState(true);
649 }
650
651 React.useEffect(() => {
652 window.addEventListener('popstate', onPopstate);
653 return () => {
654 window.removeEventListener('popstate', onPopstate);
655 };
656 }, []);
657
658 Scheduler.log(`render:${hasNavigated}/${syncState}`);
659 return null;
660 }
661 const root = ReactDOMClient.createRoot(container);
662 await act(async () => {
663 root.render(<App />);
664 });
665 assertLog(['render:false/false']);
666
667 React.startTransition(() => {
668 setHasNavigated(true);
669 });
670 await act(async () => {
671 const popStateEvent = new Event('popstate');
672 // Jest is not emulating window.event correctly in the microtask
673 window.event = popStateEvent;
674 window.dispatchEvent(popStateEvent);
675 queueMicrotask(() => {
676 window.event = undefined;
677 });
678 });
679 assertLog(['render:false/true', 'render:true/true']);
680 await act(() => {
681 root.unmount();
682 });
683 });
684
685 it('transition lane in popState should be allowed to suspend', async () => {
686 let resolvePromise;
687 const promise = new Promise(res => {
688 resolvePromise = res;
689 });
690
691 function Text({text}) {
692 Scheduler.log(text);
693 return text;
694 }
695
696 function App() {
697 const [pathname, setPathname] = React.useState('/path/a');
698
699 if (pathname !== '/path/a') {
700 try {
701 React.use(promise);
702 } catch (e) {
703 Scheduler.log(`Suspend! [${pathname}]`);
704 throw e;
705 }
706 }
707
708 React.useEffect(() => {
709 function onPopstate() {
710 React.startTransition(() => {
711 setPathname('/path/b');
712 });
713 }
714 window.addEventListener('popstate', onPopstate);
715 return () => window.removeEventListener('popstate', onPopstate);
716 }, []);
717
718 return (
719 <>
720 <Text text="Before" />
721 <div>
722 <Text text={pathname} />
723 </div>
724 <Text text="After" />
725 </>
726 );
727 }
728
729 const root = ReactDOMClient.createRoot(container);
730 await act(async () => {
731 root.render(<App />);
732 });
733 assertLog(['Before', '/path/a', 'After']);
734
735 const div = container.getElementsByTagName('div')[0];
736 expect(div.textContent).toBe('/path/a');
737
738 // Simulate a popstate event
739 await act(async () => {
740 const popStateEvent = new Event('popstate');
741
742 // Simulate a popstate event
743 window.event = popStateEvent;
744 window.dispatchEvent(popStateEvent);
745 await waitForMicrotasks();
746 window.event = undefined;
747
748 // The transition lane should have been attempted synchronously (in
749 // a microtask)
750 assertLog(['Suspend! [/path/b]']);
751 // Because it suspended, it remains on the current path
752 expect(div.textContent).toBe('/path/a');
753 });
754 // pre-warming
755 assertLog(['Suspend! [/path/b]']);
756
757 await act(async () => {
758 resolvePromise();
759
760 // Since the transition previously suspended, there's no need for this
761 // transition to be rendered synchronously on susbequent attempts; if we
762 // fail to commit synchronously the first time, the scroll restoration
763 // state won't be restored anyway.
764 //
765 // Yield in between each child to prove that it's concurrent.
766 await waitForMicrotasks();
767 assertLog([]);
768
769 await waitFor(['Before']);
770 await waitFor(['/path/b']);
771 await waitFor(['After']);
772 });
773 assertLog([]);
774 expect(div.textContent).toBe('/path/b');
775 await act(() => {
776 root.unmount();
777 });
778 });
779
780 it('regression: useDeferredValue in popState leads to infinite deferral loop', async () => {
781 // At the time this test was written, it simulated a particular crash that
782 // was happened due to a combination of very subtle implementation details.
783 // Rather than couple this test to those implementation details, I've chosen
784 // to keep it as high-level as possible so that it doesn't break if the
785 // details change. In the future, it might not be trigger the exact set of
786 // internal circumstances anymore, but it could be useful for catching
787 // similar bugs because it represents a realistic real world situation —
788 // namely, switching tabs repeatedly in an app that uses useDeferredValue.
789 //
790 // But don't worry too much about why this test is written the way it is.
791
792 // Represents the browser's current location
793 let browserPathname = '/path/a';
794
795 let setPathname;
796 function App({initialPathname}) {
797 const [pathname, _setPathname] = React.useState('/path/a');
798 setPathname = _setPathname;
799
800 const deferredPathname = React.useDeferredValue(pathname);
801
802 // Attach a popstate listener on mount. Normally this would be in the
803 // in the router implementation.
804 React.useEffect(() => {
805 function onPopstate() {
806 React.startTransition(() => {
807 setPathname(browserPathname);
808 });
809 }
810 window.addEventListener('popstate', onPopstate);
811 return () => window.removeEventListener('popstate', onPopstate);
812 }, []);
813
814 return `Current: ${pathname}\nDeferred: ${deferredPathname}`;
815 }
816
817 const root = ReactDOMClient.createRoot(container);
818 await act(async () => {
819 root.render(<App initialPathname={browserPathname} />);
820 });
821
822 // Simulate a series of popstate events that toggle back and forth between
823 // two locations. In the original regression case, a certain combination
824 // of transition lanes would cause React to fall into an infinite deferral
825 // loop — specifically, when the spawned by the useDeferredValue hook was
826 // assigned a "higher" bit value than the one assigned to the "popstate".
827
828 // For alignment reasons, call this once to advance the internal variable
829 // that assigns transition lanes. Because this is a no-op update, it will
830 // bump the counter, but it won't trigger the useDeferredValue hook.
831 setPathname(browserPathname);
832
833 // Trigger enough popstate events that the scenario occurs for every
834 // possible transition lane.
835 for (let i = 0; i < 50; i++) {
836 await act(async () => {
837 // Simulate a popstate event
838 browserPathname = browserPathname === '/path/a' ? '/path/b' : '/path/a';
839 const popStateEvent = new Event('popstate');
840 window.event = popStateEvent;
841 window.dispatchEvent(popStateEvent);
842 await waitForMicrotasks();
843 window.event = undefined;
844 });
845 }
846 });
847
848 it('regression: infinite deferral loop caused by unstable useDeferredValue input', async () => {
849 function Text({text}) {
850 Scheduler.log(text);
851 return text;
852 }
853
854 let i = 0;
855 function App() {
856 const [pathname, setPathname] = React.useState('/path/a');
857 // This is an unstable input, so it will always cause a deferred render.
858 const {value: deferredPathname} = React.useDeferredValue({
859 value: pathname,
860 });
861 if (i++ > 100) {
862 throw new Error('Infinite loop detected');
863 }
864 React.useEffect(() => {
865 function onPopstate() {
866 React.startTransition(() => {
867 setPathname('/path/b');
868 });
869 }
870 window.addEventListener('popstate', onPopstate);
871 return () => window.removeEventListener('popstate', onPopstate);
872 }, []);
873
874 return <Text text={deferredPathname} />;
875 }
876
877 const root = ReactDOMClient.createRoot(container);
878 await act(() => {
879 root.render(<App />);
880 });
881 assertLog(['/path/a']);
882 expect(container.textContent).toBe('/path/a');
883
884 // Simulate a popstate event
885 await act(async () => {
886 const popStateEvent = new Event('popstate');
887
888 // Simulate a popstate event
889 window.event = popStateEvent;
890 window.dispatchEvent(popStateEvent);
891 await waitForMicrotasks();
892 window.event = undefined;
893
894 // The transition lane is attempted synchronously (in a microtask).
895 // Because the input to useDeferredValue is referentially unstable, it
896 // will spawn a deferred task at transition priority. However, even
897 // though it was spawned during a transition event, the spawned task
898 // not also be upgraded to sync.
899 assertLog(['/path/a']);
900 });
901 assertLog(['/path/b']);
902 expect(container.textContent).toBe('/path/b');
903 await act(() => {
904 root.unmount();
905 });
906 });
907 });