@samitouri / QOS-React-2 / commits / 0b931f90e8

Remove JND delay for non-transition updates (#26597)

Updates that are marked as part of a transition are allowed to block a render from committing. Generally, other updates cannot — however, there's one exception that's leftover from a previous iteration of our Suspense architecture. If an update is not the result of a known urgent event type — known as "Default" updates — then we allow it to suspend briefly, as long as the delay is short enough that the user won't notice. We refer to this delay as a "Just Noticable Difference" (JND) delay. To illustrate, if the user has already waited 400ms for an update to be reflected on the screen, the theory is that they won't notice if you wait an additional 100ms. So React can suspend for a bit longer in case more data comes in. The longer the user has already waited, the longer the JND. While we still believe this theory is sound from a UX perspective, we no longer think the implementation complexity is worth it. The main thing that's changed is how we handle Default updates. We used to render Default updates concurrently (i.e. they were time sliced, and were scheduled with postTask), but now they are blocking. Soon, they will also be scheduled with rAF, too, which means by the end of the next rAF, they will have either finished rendering or the main thread will be blocked until they do. There are various motivations for this but part of the rationale is that anything that can be made non-blocking should be marked as a Transition, anyway, so it's not worth adding implementation complexity to Default. This commit removes the JND delay for Default updates. They will now commit immediately once the render phase is complete, even if a component suspends.

Andrew Clark committed Apr 11, 2023 at 00:19 UTC 0b931f90e8964183f08ac328e7350d847abb08f9
10 files changed +114 -743
packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js
+2 -2
@@ -236,7 +236,7 @@ describe('ReactCache', () => {
236
237 jest.advanceTimersByTime(100);
238 assertLog(['Promise resolved [4]']);
239 - await waitForAll([1, 4, 'Suspend! [5]', 'Loading...']);
239 + await waitForAll([1, 4, 'Suspend! [5]']);
240
241 jest.advanceTimersByTime(100);
242 assertLog(['Promise resolved [5]']);
@@ -264,7 +264,7 @@ describe('ReactCache', () => {
264 ]);
265 jest.advanceTimersByTime(100);
266 assertLog(['Promise resolved [2]']);
267 - await waitForAll([1, 2, 'Suspend! [3]', 'Loading...']);
267 + await waitForAll([1, 2, 'Suspend! [3]']);
268
269 jest.advanceTimersByTime(100);
270 assertLog(['Promise resolved [3]']);
packages/react-reconciler/src/ReactFiberWorkLoop.js
-60
@@ -145,7 +145,6 @@ import {
145 includesExpiredLane,
146 getNextLanes,
147 getLanesToRetrySynchronouslyOnError,
148 - getMostRecentEventTime,
148 markRootUpdated,
149 markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
150 markRootPinged,
@@ -284,8 +283,6 @@ import {
283 } from './ReactFiberRootScheduler';
284 import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
285
287 -const ceil = Math.ceil;
288 -
286 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
287
288 const {
@@ -1193,38 +1190,6 @@ function finishConcurrentRender(
1190 break;
1191 }
1192
1196 - if (!shouldForceFlushFallbacksInDEV()) {
1197 - // This is not a transition, but we did trigger an avoided state.
1198 - // Schedule a placeholder to display after a short delay, using the Just
1199 - // Noticeable Difference.
1200 - // TODO: Is the JND optimization worth the added complexity? If this is
1201 - // the only reason we track the event time, then probably not.
1202 - // Consider removing.
1203 -
1204 - const mostRecentEventTime = getMostRecentEventTime(root, lanes);
1205 - const eventTimeMs = mostRecentEventTime;
1206 - const timeElapsedMs = now() - eventTimeMs;
1207 - const msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs;
1208 -
1209 - // Don't bother with a very short suspense time.
1210 - if (msUntilTimeout > 10) {
1211 - // Instead of committing the fallback immediately, wait for more data
1212 - // to arrive.
1213 - root.timeoutHandle = scheduleTimeout(
1214 - commitRootWhenReady.bind(
1215 - null,
1216 - root,
1217 - finishedWork,
1218 - workInProgressRootRecoverableErrors,
1219 - workInProgressTransitions,
1220 - lanes,
1221 - ),
1222 - msUntilTimeout,
1223 - );
1224 - break;
1225 - }
1226 - }
1227 -
1193 // Commit the placeholder.
1194 commitRootWhenReady(
1195 root,
@@ -3580,31 +3545,6 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
3545 retryTimedOutBoundary(boundaryFiber, retryLane);
3546 }
3547
3583 -// Computes the next Just Noticeable Difference (JND) boundary.
3584 -// The theory is that a person can't tell the difference between small differences in time.
3585 -// Therefore, if we wait a bit longer than necessary that won't translate to a noticeable
3586 -// difference in the experience. However, waiting for longer might mean that we can avoid
3587 -// showing an intermediate loading state. The longer we have already waited, the harder it
3588 -// is to tell small differences in time. Therefore, the longer we've already waited,
3589 -// the longer we can wait additionally. At some point we have to give up though.
3590 -// We pick a train model where the next boundary commits at a consistent schedule.
3591 -// These particular numbers are vague estimates. We expect to adjust them based on research.
3592 -function jnd(timeElapsed: number) {
3593 - return timeElapsed < 120
3594 - ? 120
3595 - : timeElapsed < 480
3596 - ? 480
3597 - : timeElapsed < 1080
3598 - ? 1080
3599 - : timeElapsed < 1920
3600 - ? 1920
3601 - : timeElapsed < 3000
3602 - ? 3000
3603 - : timeElapsed < 4320
3604 - ? 4320
3605 - : ceil(timeElapsed / 1960) * 1960;
3606 -}
3607 -
3548 export function throwIfInfiniteUpdateLoopDetected() {
3549 if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
3550 nestedUpdateCount = 0;
packages/react-reconciler/src/__tests__/ReactExpiration-test.js
+2 -6
@@ -732,13 +732,9 @@ describe('ReactExpiration', () => {
732 expect(root).toMatchRenderedOutput('A0BC');
733
734 await act(async () => {
735 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
736 - React.startTransition(() => {
737 - root.render(<App step={1} />);
738 - });
739 - } else {
735 + React.startTransition(() => {
736 root.render(<App step={1} />);
741 - }
737 + });
738 await waitForAll(['Suspend! [A1]', 'Loading...']);
739
740 // Lots of time elapses before the promise resolves
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+10 -31
@@ -692,24 +692,16 @@ describe('ReactHooksWithNoopRenderer', () => {
692 await waitForAll([0]);
693 expect(root).toMatchRenderedOutput(<span prop={0} />);
694
695 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
696 - React.startTransition(() => {
697 - root.render(<Foo signal={false} />);
698 - });
699 - } else {
695 + React.startTransition(() => {
696 root.render(<Foo signal={false} />);
701 - }
697 + });
698 await waitForAll(['Suspend!']);
699 expect(root).toMatchRenderedOutput(<span prop={0} />);
700
701 // Rendering again should suspend again.
706 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
707 - React.startTransition(() => {
708 - root.render(<Foo signal={false} />);
709 - });
710 - } else {
702 + React.startTransition(() => {
703 root.render(<Foo signal={false} />);
712 - }
704 + });
705 await waitForAll(['Suspend!']);
706 });
707
@@ -755,38 +747,25 @@ describe('ReactHooksWithNoopRenderer', () => {
747 expect(root).toMatchRenderedOutput(<span prop="A:0" />);
748
749 await act(async () => {
758 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
759 - React.startTransition(() => {
760 - root.render(<Foo signal={false} />);
761 - setLabel('B');
762 - });
763 - } else {
750 + React.startTransition(() => {
751 root.render(<Foo signal={false} />);
752 setLabel('B');
766 - }
753 + });
754
755 await waitForAll(['Suspend!']);
756 expect(root).toMatchRenderedOutput(<span prop="A:0" />);
757
758 // Rendering again should suspend again.
772 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
773 - React.startTransition(() => {
774 - root.render(<Foo signal={false} />);
775 - });
776 - } else {
759 + React.startTransition(() => {
760 root.render(<Foo signal={false} />);
778 - }
761 + });
762 await waitForAll(['Suspend!']);
763
764 // Flip the signal back to "cancel" the update. However, the update to
765 // label should still proceed. It shouldn't have been dropped.
783 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
784 - React.startTransition(() => {
785 - root.render(<Foo signal={true} />);
786 - });
787 - } else {
766 + React.startTransition(() => {
767 root.render(<Foo signal={true} />);
789 - }
768 + });
769 await waitForAll(['B:0']);
770 expect(root).toMatchRenderedOutput(<span prop="B:0" />);
771 });
packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+8 -10
@@ -1414,10 +1414,12 @@ describe('ReactLazy', () => {
1414
1415 // Swap the position of A and B
1416 root.update(<Parent swap={true} />);
1417 - await waitForAll(['Init B2', 'Loading...']);
1418 - jest.runAllTimers();
1419 -
1420 - assertLog(['Did unmount: A', 'Did unmount: B']);
1417 + await waitForAll([
1418 + 'Init B2',
1419 + 'Loading...',
1420 + 'Did unmount: A',
1421 + 'Did unmount: B',
1422 + ]);
1423
1424 // The suspense boundary should've triggered now.
1425 expect(root).toMatchRenderedOutput('Loading...');
@@ -1559,13 +1561,9 @@ describe('ReactLazy', () => {
1561 expect(root).toMatchRenderedOutput('AB');
1562
1563 // Swap the position of A and B
1562 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
1563 - React.startTransition(() => {
1564 - root.update(<Parent swap={true} />);
1565 - });
1566 - } else {
1564 + React.startTransition(() => {
1565 root.update(<Parent swap={true} />);
1568 - }
1566 + });
1567 await waitForAll(['Init B2', 'Loading...']);
1568 await resolveFakeImport(ChildB2);
1569 // We need to flush to trigger the second one to load.
packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
+29 -43
@@ -9,6 +9,7 @@ let useState;
9 let useEffect;
10 let startTransition;
11 let textCache;
12 +let waitFor;
13 let waitForPaint;
14 let assertLog;
15
@@ -28,6 +29,7 @@ describe('ReactOffscreen', () => {
29 startTransition = React.startTransition;
30
31 const InternalTestUtils = require('internal-test-utils');
32 + waitFor = InternalTestUtils.waitFor;
33 waitForPaint = InternalTestUtils.waitForPaint;
34 assertLog = InternalTestUtils.assertLog;
35
@@ -407,7 +409,6 @@ describe('ReactOffscreen', () => {
409 expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
410 });
411
410 - // Only works in new reconciler
412 // @gate enableOffscreen
413 test('detect updates to a hidden tree during a concurrent event', async () => {
414 // This is a pretty complex test case. It relates to how we detect if an
@@ -442,17 +443,17 @@ describe('ReactOffscreen', () => {
443 setOuter = _setOuter;
444 return (
445 <>
445 - <span>
446 - <Text text={'Outer: ' + outer} />
447 - </span>
446 <Offscreen mode={show ? 'visible' : 'hidden'}>
447 <span>
448 <Child outer={outer} />
449 </span>
450 </Offscreen>
451 + <span>
452 + <Text text={'Outer: ' + outer} />
453 + </span>
454 <Suspense fallback={<Text text="Loading..." />}>
455 <span>
455 - <AsyncText text={'Async: ' + outer} />
456 + <Text text={'Sibling: ' + outer} />
457 </span>
458 </Suspense>
459 </>
@@ -466,50 +467,41 @@ describe('ReactOffscreen', () => {
467 root.render(<App show={true} />);
468 });
469 assertLog([
469 - 'Outer: 0',
470 'Inner: 0',
471 - 'Async: 0',
471 + 'Outer: 0',
472 + 'Sibling: 0',
473 'Inner and outer are consistent',
474 ]);
475 expect(root).toMatchRenderedOutput(
476 <>
476 - <span>Outer: 0</span>
477 <span>Inner: 0</span>
478 - <span>Async: 0</span>
478 + <span>Outer: 0</span>
479 + <span>Sibling: 0</span>
480 </>,
481 );
482
483 await act(async () => {
484 // Update a value both inside and outside the hidden tree. These values
485 // must always be consistent.
485 - setOuter(1);
486 - setInner(1);
487 - // In the same render, also hide the offscreen tree.
488 - root.render(<App show={false} />);
486 + startTransition(() => {
487 + setOuter(1);
488 + setInner(1);
489 + // In the same render, also hide the offscreen tree.
490 + root.render(<App show={false} />);
491 + });
492
490 - await waitForPaint([
493 + await waitFor([
494 // The outer update will commit, but the inner update is deferred until
495 // a later render.
496 'Outer: 1',
494 -
495 - // Something suspended. This means we won't commit immediately; there
496 - // will be an async gap between render and commit. In this test, we will
497 - // use this property to schedule a concurrent update. The fact that
498 - // we're using Suspense to schedule a concurrent update is not directly
499 - // relevant to the test — we could also use time slicing, but I've
500 - // chosen to use Suspense the because implementation details of time
501 - // slicing are more volatile.
502 - 'Suspend! [Async: 1]',
503 -
504 - 'Loading...',
497 ]);
498
499 // Assert that we haven't committed quite yet
500 expect(root).toMatchRenderedOutput(
501 <>
510 - <span>Outer: 0</span>
502 <span>Inner: 0</span>
512 - <span>Async: 0</span>
503 + <span>Outer: 0</span>
504 + <span>Sibling: 0</span>
505 </>,
506 );
507
@@ -520,14 +512,13 @@ describe('ReactOffscreen', () => {
512 setInner(2);
513 });
514
523 - // Commit the previous render.
524 - jest.runAllTimers();
515 + // Finish rendering and commit the in-progress render.
516 + await waitForPaint(['Sibling: 1']);
517 expect(root).toMatchRenderedOutput(
518 <>
527 - <span>Outer: 1</span>
519 <span hidden={true}>Inner: 0</span>
529 - <span hidden={true}>Async: 0</span>
530 - Loading...
520 + <span>Outer: 1</span>
521 + <span>Sibling: 1</span>
522 </>,
523 );
524
@@ -536,32 +527,27 @@ describe('ReactOffscreen', () => {
527 root.render(<App show={true} />);
528 });
529 assertLog([
539 - 'Outer: 1',
540 -
530 // There are two pending updates on Inner, but only the first one
531 // is processed, even though they share the same lane. If the second
532 // update were erroneously processed, then Inner would be inconsistent
533 // with Outer.
534 'Inner: 1',
546 -
547 - 'Suspend! [Async: 1]',
548 - 'Loading...',
535 + 'Outer: 1',
536 + 'Sibling: 1',
537 'Inner and outer are consistent',
538 ]);
539 });
540 assertLog([
553 - 'Outer: 2',
541 'Inner: 2',
555 - 'Suspend! [Async: 2]',
556 - 'Loading...',
542 + 'Outer: 2',
543 + 'Sibling: 2',
544 'Inner and outer are consistent',
545 ]);
546 expect(root).toMatchRenderedOutput(
547 <>
561 - <span>Outer: 2</span>
548 <span>Inner: 2</span>
563 - <span hidden={true}>Async: 0</span>
564 - Loading...
549 + <span>Outer: 2</span>
550 + <span>Sibling: 2</span>
551 </>,
552 );
553 });
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+2 -6
@@ -125,13 +125,9 @@ describe('ReactSuspense', () => {
125
126 // Navigate the shell to now render the child content.
127 // This should suspend.
128 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
129 - React.startTransition(() => {
130 - root.update(<Foo renderBar={true} />);
131 - });
132 - } else {
128 + React.startTransition(() => {
129 root.update(<Foo renderBar={true} />);
134 - }
130 + });
131
132 await waitForAll([
133 'Foo',
packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js
+12 -117
@@ -576,7 +576,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
576 ]);
577 });
578
579 - // @gate enableLegacyCache && enableSyncDefaultUpdates
579 + // @gate enableLegacyCache
580 it('should be destroyed and recreated for function components', async () => {
581 function App({children = null}) {
582 Scheduler.log('App render');
@@ -642,19 +642,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
642 'Suspend:Async',
643 'Text:Fallback render',
644 'Text:Outside render',
645 - ]);
646 - expect(ReactNoop).toMatchRenderedOutput(
647 - <>
648 - <span prop="Inside:Before" />
649 - <span prop="Inside:After" />
650 - <span prop="Outside" />
651 - </>,
652 - );
653 -
654 - await jest.runAllTimers();
655 -
656 - // Timing out should commit the fallback and destroy inner layout effects.
657 - assertLog([
645 'Text:Inside:Before destroy layout',
646 'Text:Inside:After destroy layout',
647 'Text:Fallback create layout',
@@ -711,7 +698,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
698 ]);
699 });
700
714 - // @gate enableLegacyCache && enableSyncDefaultUpdates
701 + // @gate enableLegacyCache
702 it('should be destroyed and recreated for class components', async () => {
703 class ClassText extends React.Component {
704 componentDidMount() {
@@ -796,19 +783,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
783 'Suspend:Async',
784 'ClassText:Fallback render',
785 'ClassText:Outside render',
799 - ]);
800 - expect(ReactNoop).toMatchRenderedOutput(
801 - <>
802 - <span prop="Inside:Before" />
803 - <span prop="Inside:After" />
804 - <span prop="Outside" />
805 - </>,
806 - );
807 -
808 - await jest.runAllTimers();
809 -
810 - // Timing out should commit the fallback and destroy inner layout effects.
811 - assertLog([
786 'ClassText:Inside:Before componentWillUnmount',
787 'ClassText:Inside:After componentWillUnmount',
788 'ClassText:Fallback componentDidMount',
@@ -860,7 +834,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
834 ]);
835 });
836
863 - // @gate enableLegacyCache && enableSyncDefaultUpdates
837 + // @gate enableLegacyCache
838 it('should be destroyed and recreated when nested below host components', async () => {
839 function App({children = null}) {
840 Scheduler.log('App render');
@@ -914,17 +888,10 @@ describe('ReactSuspenseEffectsSemantics', () => {
888 <AsyncText text="Async" ms={1000} />
889 </App>,
890 );
917 - await waitFor(['App render', 'Suspend:Async', 'Text:Fallback render']);
918 - expect(ReactNoop).toMatchRenderedOutput(
919 - <span prop="Outer">
920 - <span prop="Inner" />
921 - </span>,
922 - );
923 -
924 - await jest.runAllTimers();
925 -
926 - // Timing out should commit the fallback and destroy inner layout effects.
927 - assertLog([
891 + await waitFor([
892 + 'App render',
893 + 'Suspend:Async',
894 + 'Text:Fallback render',
895 'Text:Outer destroy layout',
896 'Text:Inner destroy layout',
897 'Text:Fallback create layout',
@@ -979,7 +946,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
946 ]);
947 });
948
982 - // @gate enableLegacyCache && enableSyncDefaultUpdates
949 + // @gate enableLegacyCache
950 it('should be destroyed and recreated even if there is a bailout because of memoization', async () => {
951 const MemoizedText = React.memo(Text, () => true);
952
@@ -1040,18 +1007,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1007 'Suspend:Async',
1008 // Text:MemoizedInner is memoized
1009 'Text:Fallback render',
1043 - ]);
1044 - expect(ReactNoop).toMatchRenderedOutput(
1045 - <span prop="Outer">
1046 - <span prop="MemoizedInner" />
1047 - </span>,
1048 - );
1049 -
1050 - await jest.runAllTimers();
1051 -
1052 - // Timing out should commit the fallback and destroy inner layout effects.
1053 - // Even though the innermost layout effects are beneath a hidden HostComponent.
1054 - assertLog([
1010 'Text:Outer destroy layout',
1011 'Text:MemoizedInner destroy layout',
1012 'Text:Fallback create layout',
@@ -1448,7 +1403,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
1403 );
1404 });
1405
1451 - // @gate enableLegacyCache && enableSyncDefaultUpdates
1406 + // @gate enableLegacyCache
1407 it('should be cleaned up inside of a fallback that suspends', async () => {
1408 function App({fallbackChildren = null, outerChildren = null}) {
1409 return (
@@ -1501,17 +1456,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1456 'Text:Fallback:Inside render',
1457 'Text:Fallback:Outside render',
1458 'Text:Outside render',
1504 - ]);
1505 - expect(ReactNoop).toMatchRenderedOutput(
1506 - <>
1507 - <span prop="Inside" />
1508 - <span prop="Outside" />
1509 - </>,
1510 - );
1511 -
1512 - // Timing out should commit the fallback and destroy inner layout effects.
1513 - await jest.runAllTimers();
1514 - assertLog([
1459 'Text:Inside destroy layout',
1460 'Text:Fallback:Inside create layout',
1461 'Text:Fallback:Outside create layout',
@@ -1546,19 +1490,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1490 'Text:Fallback:Fallback render',
1491 'Text:Fallback:Outside render',
1492 'Text:Outside render',
1549 - ]);
1550 - expect(ReactNoop).toMatchRenderedOutput(
1551 - <>
1552 - <span prop="Inside" hidden={true} />
1553 - <span prop="Fallback:Inside" />
1554 - <span prop="Fallback:Outside" />
1555 - <span prop="Outside" />
1556 - </>,
1557 - );
1558 -
1559 - // Timing out should commit the inner fallback and destroy outer fallback layout effects.
1560 - await jest.runAllTimers();
1561 - assertLog([
1493 'Text:Fallback:Inside destroy layout',
1494 'Text:Fallback:Fallback create layout',
1495 ]);
@@ -1724,7 +1655,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
1655 );
1656 });
1657
1727 - // @gate enableLegacyCache && enableSyncDefaultUpdates
1658 + // @gate enableLegacyCache
1659 it('should be cleaned up deeper inside of a subtree that suspends', async () => {
1660 function ConditionalSuspense({shouldSuspend}) {
1661 if (shouldSuspend) {
@@ -1771,17 +1702,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
1702 'Suspend:Suspend',
1703 'Text:Fallback render',
1704 'Text:Outside render',
1774 - ]);
1775 - expect(ReactNoop).toMatchRenderedOutput(
1776 - <>
1777 - <span prop="Inside" />
1778 - <span prop="Outside" />
1779 - </>,
1780 - );
1781 -
1782 - // Timing out should commit the inner fallback and destroy outer fallback layout effects.
1783 - await jest.runAllTimers();
1784 - assertLog([
1705 'Text:Inside destroy layout',
1706 'Text:Fallback create layout',
1707 ]);
@@ -2305,7 +2225,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
2225 });
2226 });
2227
2308 - // @gate enableLegacyCache && enableSyncDefaultUpdates
2228 + // @gate enableLegacyCache
2229 it('should be only destroy layout effects once if a tree suspends in multiple places', async () => {
2230 class ClassText extends React.Component {
2231 componentDidMount() {
@@ -2366,18 +2286,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2286 'Text:Function render',
2287 'Suspend:Async_1',
2288 'ClassText:Fallback render',
2369 - ]);
2370 - expect(ReactNoop).toMatchRenderedOutput(
2371 - <>
2372 - <span prop="Function" />
2373 - <span prop="Class" />
2374 - </>,
2375 - );
2376 -
2377 - await jest.runAllTimers();
2378 -
2379 - // Timing out should commit the fallback and destroy inner layout effects.
2380 - assertLog([
2289 'Text:Function destroy layout',
2290 'ClassText:Class componentWillUnmount',
2291 'ClassText:Fallback componentDidMount',
@@ -2448,7 +2356,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
2356 ]);
2357 });
2358
2451 - // @gate enableLegacyCache && enableSyncDefaultUpdates
2359 + // @gate enableLegacyCache
2360 it('should be only destroy layout effects once if a component suspends multiple times', async () => {
2361 class ClassText extends React.Component {
2362 componentDidMount() {
@@ -2518,19 +2426,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
2426 'Suspender "A" render',
2427 'Suspend:A',
2428 'ClassText:Fallback render',
2521 - ]);
2522 - expect(ReactNoop).toMatchRenderedOutput(
2523 - <>
2524 - <span prop="Function" />
2525 - <span prop="Suspender" />
2526 - <span prop="Class" />
2527 - </>,
2528 - );
2529 -
2530 - await jest.runAllTimers();
2531 -
2532 - // Timing out should commit the fallback and destroy inner layout effects.
2533 - assertLog([
2429 'Text:Function destroy layout',
2430 'ClassText:Class componentWillUnmount',
2431 'ClassText:Fallback componentDidMount',
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+7 -8
@@ -488,13 +488,12 @@ describe('ReactSuspensePlaceholder', () => {
488 'Suspend! [Loaded]',
489 'Fallback',
490 ]);
491 - expect(ReactNoop).toMatchRenderedOutput('Text');
492 -
491 // Show the fallback UI.
494 - jest.advanceTimersByTime(900);
492 expect(ReactNoop).toMatchRenderedOutput('Loading...');
493 expect(onRender).toHaveBeenCalledTimes(2);
494
495 + jest.advanceTimersByTime(900);
496 +
497 // The suspense update should only show the "Loading..." Fallback.
498 // The actual duration should include 10ms spent rendering Fallback,
499 // plus the 3ms render all of the partially rendered suspended subtree.
@@ -529,19 +528,19 @@ describe('ReactSuspensePlaceholder', () => {
528 'Suspend! [Sibling]',
529 ]);
530 expect(ReactNoop).toMatchRenderedOutput('Loading...');
532 - expect(onRender).toHaveBeenCalledTimes(2);
531 + expect(onRender).toHaveBeenCalledTimes(3);
532
533 // Resolve the pending promise.
534 jest.advanceTimersByTime(100);
535 assertLog(['Promise resolved [Loaded]', 'Promise resolved [Sibling]']);
537 - await waitForAll(['App', 'Suspending', 'Loaded', 'New', 'Sibling']);
538 - expect(onRender).toHaveBeenCalledTimes(3);
536 + await waitForAll(['Suspending', 'Loaded', 'New', 'Sibling']);
537 + expect(onRender).toHaveBeenCalledTimes(4);
538
539 // When the suspending data is resolved and our final UI is rendered,
540 // both times should include the 6ms rendering Text,
541 // the 2ms rendering Suspending, and the 1ms rendering AsyncText.
543 - expect(onRender.mock.calls[2][2]).toBe(9);
544 - expect(onRender.mock.calls[2][3]).toBe(9);
542 + expect(onRender.mock.calls[3][2]).toBe(9);
543 + expect(onRender.mock.calls[3][3]).toBe(9);
544 });
545 });
546 });
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+42 -460
@@ -289,13 +289,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
289 await waitForAll(['Foo']);
290
291 // The update will suspend.
292 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
293 - React.startTransition(() => {
294 - ReactNoop.render(<Foo renderBar={true} />);
295 - });
296 - } else {
292 + React.startTransition(() => {
293 ReactNoop.render(<Foo renderBar={true} />);
298 - }
294 + });
295 await waitForAll([
296 'Foo',
297 'Bar',
@@ -371,22 +367,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
367 });
368
369 // @gate enableLegacyCache
374 - it('continues rendering siblings after suspending', async () => {
370 + it('when something suspends, unwinds immediately without rendering siblings', async () => {
371 // A shell is needed. The update cause it to suspend.
372 ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
373 await waitForAll([]);
378 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
379 - React.startTransition(() => {
380 - ReactNoop.render(
381 - <Suspense fallback={<Text text="Loading..." />}>
382 - <Text text="A" />
383 - <AsyncText text="B" />
384 - <Text text="C" />
385 - <Text text="D" />
386 - </Suspense>,
387 - );
388 - });
389 - } else {
374 + React.startTransition(() => {
375 ReactNoop.render(
376 <Suspense fallback={<Text text="Loading..." />}>
377 <Text text="A" />
@@ -395,7 +380,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
380 <Text text="D" />
381 </Suspense>,
382 );
398 - }
383 + });
384 +
385 // B suspends. Render a fallback
386 await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
387 // Did not commit yet.
@@ -453,13 +439,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
439 await waitForAll([]);
440 expect(ReactNoop).toMatchRenderedOutput(null);
441
456 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
457 - React.startTransition(() => {
458 - ReactNoop.render(<App renderContent={true} />);
459 - });
460 - } else {
442 + React.startTransition(() => {
443 ReactNoop.render(<App renderContent={true} />);
462 - }
444 + });
445 await waitForAll(['Suspend! [Result]', 'Loading...']);
446 expect(ReactNoop).toMatchRenderedOutput(null);
447
@@ -588,9 +570,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
570
571 // @gate enableLegacyCache
572 it('keeps working on lower priority work after being pinged', async () => {
591 - // Advance the virtual time so that we're close to the edge of a bucket.
592 - ReactNoop.expire(149);
593 -
573 function App(props) {
574 return (
575 <Suspense fallback={<Text text="Loading..." />}>
@@ -604,26 +583,15 @@ describe('ReactSuspenseWithNoopRenderer', () => {
583 await waitForAll([]);
584 expect(ReactNoop).toMatchRenderedOutput(null);
585
607 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
608 - React.startTransition(() => {
609 - ReactNoop.render(<App showA={true} showB={false} />);
610 - });
611 - } else {
586 + React.startTransition(() => {
587 ReactNoop.render(<App showA={true} showB={false} />);
613 - }
588 + });
589 await waitForAll(['Suspend! [A]', 'Loading...']);
590 expect(ReactNoop).toMatchRenderedOutput(null);
591
617 - // Advance React's virtual time by enough to fall into a new async bucket,
618 - // but not enough to expire the suspense timeout.
619 - ReactNoop.expire(120);
620 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
621 - React.startTransition(() => {
622 - ReactNoop.render(<App showA={true} showB={true} />);
623 - });
624 - } else {
592 + React.startTransition(() => {
593 ReactNoop.render(<App showA={true} showB={true} />);
626 - }
594 + });
595 await waitForAll(['Suspend! [A]', 'Loading...']);
596 expect(ReactNoop).toMatchRenderedOutput(null);
597
@@ -744,61 +712,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
712 assertLog(['Sibling', 'Step 4']);
713 });
714
747 - // @gate enableLegacyCache
748 - it('forces an expiration after an update times out', async () => {
749 - ReactNoop.render(
750 - <Fragment>
751 - <Suspense fallback={<Text text="Loading..." />} />
752 - </Fragment>,
753 - );
754 - await waitForAll([]);
755 -
756 - ReactNoop.render(
757 - <Fragment>
758 - <Suspense fallback={<Text text="Loading..." />}>
759 - <AsyncText text="Async" />
760 - </Suspense>
761 - <Text text="Sync" />
762 - </Fragment>,
763 - );
764 -
765 - await waitForAll([
766 - // The async child suspends
767 - 'Suspend! [Async]',
768 - // Render the placeholder
769 - 'Loading...',
770 - // Continue on the sibling
771 - 'Sync',
772 - ]);
773 - // The update hasn't expired yet, so we commit nothing.
774 - expect(ReactNoop).toMatchRenderedOutput(null);
775 -
776 - // Advance both React's virtual time and Jest's timers by enough to expire
777 - // the update.
778 - ReactNoop.expire(10000);
779 - await advanceTimers(10000);
780 - // No additional rendering work is required, since we already prepared
781 - // the placeholder.
782 - assertLog([]);
783 - // Should have committed the placeholder.
784 - expect(ReactNoop).toMatchRenderedOutput(
785 - <>
786 - <span prop="Loading..." />
787 - <span prop="Sync" />
788 - </>,
789 - );
790 -
791 - // Once the promise resolves, we render the suspended view
792 - await resolveText('Async');
793 - await waitForAll(['Async']);
794 - expect(ReactNoop).toMatchRenderedOutput(
795 - <>
796 - <span prop="Async" />
797 - <span prop="Sync" />
798 - </>,
799 - );
800 - });
801 -
715 // @gate enableLegacyCache
716 it('switches to an inner fallback after suspending for a while', async () => {
717 // Advance the virtual time so that we're closer to the edge of a bucket.
@@ -934,109 +847,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
847 expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading (outer)..." />);
848 });
849
937 - // @gate enableLegacyCache
938 - it('expires early by default', async () => {
939 - ReactNoop.render(
940 - <Fragment>
941 - <Suspense fallback={<Text text="Loading..." />} />
942 - </Fragment>,
943 - );
944 - await waitForAll([]);
945 -
946 - ReactNoop.render(
947 - <Fragment>
948 - <Suspense fallback={<Text text="Loading..." />}>
949 - <AsyncText text="Async" />
950 - </Suspense>
951 - <Text text="Sync" />
952 - </Fragment>,
953 - );
954 -
955 - await waitForAll([
956 - // The async child suspends
957 - 'Suspend! [Async]',
958 - 'Loading...',
959 - // Continue on the sibling
960 - 'Sync',
961 - ]);
962 - // The update hasn't expired yet, so we commit nothing.
963 - expect(ReactNoop).toMatchRenderedOutput(null);
964 -
965 - // Advance both React's virtual time and Jest's timers by enough to trigger
966 - // the timeout, but not by enough to flush the promise or reach the true
967 - // expiration time.
968 - ReactNoop.expire(2000);
969 - await advanceTimers(2000);
970 - await waitForAll([]);
971 - expect(ReactNoop).toMatchRenderedOutput(
972 - <>
973 - <span prop="Loading..." />
974 - <span prop="Sync" />
975 - </>,
976 - );
977 -
978 - // Once the promise resolves, we render the suspended view
979 - await resolveText('Async');
980 - await waitForAll(['Async']);
981 - expect(ReactNoop).toMatchRenderedOutput(
982 - <>
983 - <span prop="Async" />
984 - <span prop="Sync" />
985 - </>,
986 - );
987 - });
988 -
989 - // @gate enableLegacyCache
990 - it('does not expire for transitions', async () => {
991 - ReactNoop.render(
992 - <Fragment>
993 - <Suspense fallback={<Text text="Loading..." />} />
994 - </Fragment>,
995 - );
996 - await waitForAll([]);
997 -
998 - React.startTransition(() => {
999 - ReactNoop.render(
1000 - <Fragment>
1001 - <Suspense fallback={<Text text="Loading..." />}>
1002 - <AsyncText text="Async" />
1003 - </Suspense>
1004 - <Text text="Sync" />
1005 - </Fragment>,
1006 - );
1007 - });
1008 -
1009 - await waitForAll([
1010 - // The async child suspends
1011 - 'Suspend! [Async]',
1012 - 'Loading...',
1013 - // Continue on the sibling
1014 - 'Sync',
1015 - ]);
1016 - // The update hasn't expired yet, so we commit nothing.
1017 - expect(ReactNoop).toMatchRenderedOutput(null);
1018 -
1019 - // Advance both React's virtual time and Jest's timers,
1020 - // but not by enough to flush the promise or reach the true expiration time.
1021 - ReactNoop.expire(2000);
1022 - await advanceTimers(2000);
1023 - // Even flushing won't yield a fallback in a transition.
1024 - expect(ReactNoop).toMatchRenderedOutput(null);
1025 -
1026 - await waitForAll([]);
1027 - expect(ReactNoop).toMatchRenderedOutput(null);
1028 -
1029 - // Once the promise resolves, we render the suspended view
1030 - await resolveText('Async');
1031 - await waitForAll(['Async', 'Sync']);
1032 - expect(ReactNoop).toMatchRenderedOutput(
1033 - <>
1034 - <span prop="Async" />
1035 - <span prop="Sync" />
1036 - </>,
1037 - );
1038 - });
1039 -
850 // @gate enableLegacyCache
851 it('resolves successfully even if fallback render is pending', async () => {
852 const root = ReactNoop.createRoot();
@@ -1129,21 +939,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
939 ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
940 await waitForAll([]);
941
1132 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
1133 - React.startTransition(() => {
1134 - ReactNoop.render(
1135 - <Suspense fallback={<Text text="Loading..." />}>
1136 - <AsyncText text="Async" />
1137 - </Suspense>,
1138 - );
1139 - });
1140 - } else {
942 + React.startTransition(() => {
943 ReactNoop.render(
944 <Suspense fallback={<Text text="Loading..." />}>
945 <AsyncText text="Async" />
946 </Suspense>,
947 );
1146 - }
948 + });
949 await waitForAll(['Suspend! [Async]', 'Loading...']);
950 expect(ReactNoop).toMatchRenderedOutput(null);
951
@@ -1910,74 +1712,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1712 ]);
1713 });
1714
1913 - // @gate enableLegacyCache
1914 - it('suspends for longer if something took a long (CPU bound) time to render', async () => {
1915 - function Foo({renderContent}) {
1916 - Scheduler.log('Foo');
1917 - return (
1918 - <Suspense fallback={<Text text="Loading..." />}>
1919 - {renderContent ? <AsyncText text="A" /> : null}
1920 - </Suspense>
1921 - );
1922 - }
1923 -
1924 - ReactNoop.render(<Foo />);
1925 - await waitForAll(['Foo']);
1926 -
1927 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
1928 - React.startTransition(() => {
1929 - ReactNoop.render(<Foo renderContent={true} />);
1930 - });
1931 - } else {
1932 - ReactNoop.render(<Foo renderContent={true} />);
1933 - }
1934 - Scheduler.unstable_advanceTime(100);
1935 - await advanceTimers(100);
1936 - // Start rendering
1937 - await waitFor(['Foo']);
1938 - // For some reason it took a long time to render Foo.
1939 - Scheduler.unstable_advanceTime(1250);
1940 - await advanceTimers(1250);
1941 - await waitForAll([
1942 - // A suspends
1943 - 'Suspend! [A]',
1944 - 'Loading...',
1945 - ]);
1946 - // We're now suspended and we haven't shown anything yet.
1947 - expect(ReactNoop).toMatchRenderedOutput(null);
1948 -
1949 - // Flush some of the time
1950 - Scheduler.unstable_advanceTime(450);
1951 - await advanceTimers(450);
1952 - // Because we've already been waiting for so long we can
1953 - // wait a bit longer. Still nothing...
1954 - await waitForAll([]);
1955 - expect(ReactNoop).toMatchRenderedOutput(null);
1956 -
1957 - // Eventually we'll show the fallback.
1958 - Scheduler.unstable_advanceTime(500);
1959 - await advanceTimers(500);
1960 - // No need to rerender.
1961 - await waitForAll([]);
1962 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
1963 - // Since this is a transition, we never fallback.
1964 - expect(ReactNoop).toMatchRenderedOutput(null);
1965 - } else {
1966 - expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
1967 - }
1968 -
1969 - // Flush the promise completely
1970 - await resolveText('A');
1971 - // Renders successfully
1972 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
1973 - // TODO: Why does this render Foo
1974 - await waitForAll(['Foo', 'A']);
1975 - } else {
1976 - await waitForAll(['A']);
1977 - }
1978 - expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
1979 - });
1980 -
1715 // @gate enableLegacyCache
1716 it('does not suspends if a fallback has been shown for a long time', async () => {
1717 function Foo() {
@@ -2088,59 +1822,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1822 );
1823 });
1824
2091 - // @gate enableLegacyCache
2092 - it('does not suspend for very long after a higher priority update', async () => {
2093 - function Foo({renderContent}) {
2094 - Scheduler.log('Foo');
2095 - return (
2096 - <Suspense fallback={<Text text="Loading..." />}>
2097 - {renderContent ? <AsyncText text="A" /> : null}
2098 - </Suspense>
2099 - );
2100 - }
2101 -
2102 - ReactNoop.render(<Foo />);
2103 - await waitForAll(['Foo']);
2104 -
2105 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
2106 - React.startTransition(() => {
2107 - ReactNoop.render(<Foo renderContent={true} />);
2108 - });
2109 - } else {
2110 - ReactNoop.render(<Foo renderContent={true} />);
2111 - }
2112 - await waitFor(['Foo']);
2113 -
2114 - // Advance some time.
2115 - Scheduler.unstable_advanceTime(100);
2116 - await advanceTimers(100);
2117 -
2118 - await waitForAll([
2119 - // A suspends
2120 - 'Suspend! [A]',
2121 - 'Loading...',
2122 - ]);
2123 -
2124 - // We're now suspended and we haven't shown anything yet.
2125 - expect(ReactNoop).toMatchRenderedOutput(null);
2126 -
2127 - // Flush some of the time
2128 - Scheduler.unstable_advanceTime(500);
2129 - jest.advanceTimersByTime(500);
2130 -
2131 - // We should have already shown the fallback.
2132 - // When we wrote this test, we inferred the start time of high priority
2133 - // updates as way earlier in the past. This test ensures that we don't
2134 - // use this assumption to add a very long JND.
2135 - await waitForAll([]);
2136 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
2137 - // Transitions never fallback.
2138 - expect(ReactNoop).toMatchRenderedOutput(null);
2139 - } else {
2140 - expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading..." />);
2141 - }
2142 - });
2143 -
1825 // TODO: flip to "warns" when this is implemented again.
1826 // @gate enableLegacyCache
1827 it('does not warn when a low priority update suspends inside a high priority update for functional components', async () => {
@@ -2499,12 +2180,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2180 }
2181
2182 await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
2502 - // Still suspended.
2503 - expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
2504 -
2505 - // Flush to skip suspended time.
2506 - Scheduler.unstable_advanceTime(600);
2507 - await advanceTimers(600);
2183
2184 if (gate(flags => flags.enableSyncDefaultUpdates)) {
2185 // Transitions never fall back.
@@ -2570,54 +2245,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2245 }
2246 });
2247
2573 - // @gate enableLegacyCache
2574 - it('commits a suspended idle pri render within a reasonable time', async () => {
2575 - function Foo({renderContent}) {
2576 - return (
2577 - <Fragment>
2578 - <Suspense fallback={<Text text="Loading A..." />}>
2579 - {renderContent ? <AsyncText text="A" /> : null}
2580 - </Suspense>
2581 - </Fragment>
2582 - );
2583 - }
2584 -
2585 - ReactNoop.render(<Foo />);
2586 - await waitForAll([]);
2587 -
2588 - ReactNoop.render(<Foo renderContent={1} />);
2589 -
2590 - // Took a long time to render. This is to ensure we get a long suspense time.
2591 - // Could also use something like startTransition to simulate this.
2592 - Scheduler.unstable_advanceTime(1500);
2593 - await advanceTimers(1500);
2594 -
2595 - await waitForAll(['Suspend! [A]', 'Loading A...']);
2596 - // We're still suspended.
2597 - expect(ReactNoop).toMatchRenderedOutput(null);
2598 -
2599 - // Schedule an update at idle pri.
2600 - ReactNoop.idleUpdates(() => ReactNoop.render(<Foo renderContent={2} />));
2601 - // We won't even work on Idle priority.
2602 - await waitForAll([]);
2603 -
2604 - // We're still suspended.
2605 - expect(ReactNoop).toMatchRenderedOutput(null);
2606 -
2607 - // Advance time a little bit.
2608 - Scheduler.unstable_advanceTime(150);
2609 - await advanceTimers(150);
2610 -
2611 - // We should not have committed yet because we had a long suspense time.
2612 - expect(ReactNoop).toMatchRenderedOutput(null);
2613 -
2614 - // Flush to skip suspended time.
2615 - Scheduler.unstable_advanceTime(600);
2616 - await advanceTimers(600);
2617 -
2618 - expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading A..." />);
2619 - });
2620 -
2248 describe('startTransition', () => {
2249 // @gate enableLegacyCache
2250 it('top level render', async () => {
@@ -3074,62 +2701,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2701 );
2702 });
2703
3077 - // TODO: This test is specifically about avoided commits that suspend for a
3078 - // JND. We may remove this behavior.
3079 - // @gate enableLegacyCache
3080 - it("suspended commit remains suspended even if there's another update at same expiration", async () => {
3081 - // Regression test
3082 - function App({text}) {
3083 - return (
3084 - <Suspense fallback="Loading...">
3085 - <AsyncText text={text} />
3086 - </Suspense>
3087 - );
3088 - }
3089 -
3090 - const root = ReactNoop.createRoot();
3091 - await act(() => {
3092 - root.render(<App text="Initial" />);
3093 - });
3094 - assertLog(['Suspend! [Initial]']);
3095 -
3096 - // Resolve initial render
3097 - await act(async () => {
3098 - await resolveText('Initial');
3099 - });
3100 - assertLog(['Initial']);
3101 - expect(root).toMatchRenderedOutput(<span prop="Initial" />);
3102 -
3103 - await act(async () => {
3104 - // Update. Since showing a fallback would hide content that's already
3105 - // visible, it should suspend for a JND without committing.
3106 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
3107 - React.startTransition(() => {
3108 - root.render(<App text="First update" />);
3109 - });
3110 - } else {
3111 - root.render(<App text="First update" />);
3112 - }
3113 - await waitForAll(['Suspend! [First update]']);
3114 -
3115 - // Should not display a fallback
3116 - expect(root).toMatchRenderedOutput(<span prop="Initial" />);
3117 -
3118 - // Update again. This should also suspend for a JND.
3119 - if (gate(flags => flags.enableSyncDefaultUpdates)) {
3120 - React.startTransition(() => {
3121 - root.render(<App text="Second update" />);
3122 - });
3123 - } else {
3124 - root.render(<App text="Second update" />);
3125 - }
3126 - await waitForAll(['Suspend! [Second update]']);
3127 -
3128 - // Should not display a fallback
3129 - expect(root).toMatchRenderedOutput(<span prop="Initial" />);
3130 - });
3131 - });
3132 -
2704 it('regression test: resets current "debug phase" after suspending', async () => {
2705 function App() {
2706 return (
@@ -3391,14 +2962,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
2962 setText('C');
2963 });
2964
3394 - await waitForAll([
3395 - // First we attempt the high pri update. It suspends.
3396 - 'Suspend! [B]',
3397 - 'Loading...',
3398 - ]);
3399 -
3400 - // Commit the placeholder to unblock the Idle update.
3401 - await advanceTimers(250);
2965 + // First we attempt the high pri update. It suspends.
2966 + await waitForPaint(['Suspend! [B]', 'Loading...']);
2967 expect(root).toMatchRenderedOutput(
2968 <>
2969 <span hidden={true} prop="A" />
@@ -3965,7 +3530,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3530 ]);
3531 expect(root).toMatchRenderedOutput(
3532 <>
3968 - <span prop="A" />
3533 + <span hidden={true} prop="A" />
3534 + <span prop="Loading..." />
3535 <span prop="B" />
3536 </>,
3537 );
@@ -4129,25 +3695,41 @@ describe('ReactSuspenseWithNoopRenderer', () => {
3695 await act(async () => {
3696 setText('B');
3697 ReactNoop.idleUpdates(() => {
4132 - setText('B');
3698 + setText('C');
3699 });
4134 - // Suspend the first update. The second update doesn't run because it has
4135 - // Idle priority.
4136 - await waitForAll(['Suspend! [B]', 'Loading...']);
3700
4138 - // Commit the fallback. Now we'll try working on Idle.
4139 - jest.runAllTimers();
3701 + // Suspend the first update. This triggers an immediate fallback because
3702 + // it wasn't wrapped in startTransition.
3703 + await waitForPaint(['Suspend! [B]', 'Loading...']);
3704 + expect(root).toMatchRenderedOutput(
3705 + <>
3706 + <span hidden={true} prop="A" />
3707 + <span prop="Loading..." />
3708 + </>,
3709 + );
3710
4141 - // It also suspends.
4142 - await waitForAll(['Suspend! [B]']);
3711 + // Once the fallback renders, proceed to the Idle update. This will
3712 + // also suspend.
3713 + await waitForAll(['Suspend! [C]']);
3714 });
3715
3716 + // Finish loading B.
3717 await act(async () => {
3718 setText('B');
3719 await resolveText('B');
3720 });
3721 + // We did not try to render the Idle update again because there have been no
3722 + // additional updates since the last time it was attempted.
3723 assertLog(['B']);
3724 expect(root).toMatchRenderedOutput(<span prop="B" />);
3725 +
3726 + // Finish loading C.
3727 + await act(async () => {
3728 + setText('C');
3729 + await resolveText('C');
3730 + });
3731 + assertLog(['C']);
3732 + expect(root).toMatchRenderedOutput(<span prop="C" />);
3733 });
3734
3735 // @gate enableLegacyCache