diff --git a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js
index 579c835878eb8..117c0b488b178 100644
--- a/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js
+++ b/packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js
@@ -236,7 +236,7 @@ describe('ReactCache', () => {
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [4]']);
- await waitForAll([1, 4, 'Suspend! [5]']);
+ await waitForAll([1, 4, 'Suspend! [5]', 'Loading...']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [5]']);
@@ -264,7 +264,7 @@ describe('ReactCache', () => {
]);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [2]']);
- await waitForAll([1, 2, 'Suspend! [3]']);
+ await waitForAll([1, 2, 'Suspend! [3]', 'Loading...']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [3]']);
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index d75cc2c47ff15..94af7e44d59e1 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -145,6 +145,7 @@ import {
includesExpiredLane,
getNextLanes,
getLanesToRetrySynchronouslyOnError,
+ getMostRecentEventTime,
markRootUpdated,
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
markRootPinged,
@@ -283,6 +284,8 @@ import {
} from './ReactFiberRootScheduler';
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
+const ceil = Math.ceil;
+
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
const {
@@ -1190,6 +1193,38 @@ function finishConcurrentRender(
break;
}
+ if (!shouldForceFlushFallbacksInDEV()) {
+ // This is not a transition, but we did trigger an avoided state.
+ // Schedule a placeholder to display after a short delay, using the Just
+ // Noticeable Difference.
+ // TODO: Is the JND optimization worth the added complexity? If this is
+ // the only reason we track the event time, then probably not.
+ // Consider removing.
+
+ const mostRecentEventTime = getMostRecentEventTime(root, lanes);
+ const eventTimeMs = mostRecentEventTime;
+ const timeElapsedMs = now() - eventTimeMs;
+ const msUntilTimeout = jnd(timeElapsedMs) - timeElapsedMs;
+
+ // Don't bother with a very short suspense time.
+ if (msUntilTimeout > 10) {
+ // Instead of committing the fallback immediately, wait for more data
+ // to arrive.
+ root.timeoutHandle = scheduleTimeout(
+ commitRootWhenReady.bind(
+ null,
+ root,
+ finishedWork,
+ workInProgressRootRecoverableErrors,
+ workInProgressTransitions,
+ lanes,
+ ),
+ msUntilTimeout,
+ );
+ break;
+ }
+ }
+
// Commit the placeholder.
commitRootWhenReady(
root,
@@ -3545,6 +3580,31 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
retryTimedOutBoundary(boundaryFiber, retryLane);
}
+// Computes the next Just Noticeable Difference (JND) boundary.
+// The theory is that a person can't tell the difference between small differences in time.
+// Therefore, if we wait a bit longer than necessary that won't translate to a noticeable
+// difference in the experience. However, waiting for longer might mean that we can avoid
+// showing an intermediate loading state. The longer we have already waited, the harder it
+// is to tell small differences in time. Therefore, the longer we've already waited,
+// the longer we can wait additionally. At some point we have to give up though.
+// We pick a train model where the next boundary commits at a consistent schedule.
+// These particular numbers are vague estimates. We expect to adjust them based on research.
+function jnd(timeElapsed: number) {
+ return timeElapsed < 120
+ ? 120
+ : timeElapsed < 480
+ ? 480
+ : timeElapsed < 1080
+ ? 1080
+ : timeElapsed < 1920
+ ? 1920
+ : timeElapsed < 3000
+ ? 3000
+ : timeElapsed < 4320
+ ? 4320
+ : ceil(timeElapsed / 1960) * 1960;
+}
+
export function throwIfInfiniteUpdateLoopDetected() {
if (nestedUpdateCount > NESTED_UPDATE_LIMIT) {
nestedUpdateCount = 0;
diff --git a/packages/react-reconciler/src/__tests__/ReactExpiration-test.js b/packages/react-reconciler/src/__tests__/ReactExpiration-test.js
index 653bc304600bb..2c38a05168a6c 100644
--- a/packages/react-reconciler/src/__tests__/ReactExpiration-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactExpiration-test.js
@@ -732,9 +732,13 @@ describe('ReactExpiration', () => {
expect(root).toMatchRenderedOutput('A0BC');
await act(async () => {
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
root.render();
- });
+ }
await waitForAll(['Suspend! [A1]', 'Loading...']);
// Lots of time elapses before the promise resolves
diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
index 1c7b25a2c9175..627dc4e856216 100644
--- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
@@ -692,16 +692,24 @@ describe('ReactHooksWithNoopRenderer', () => {
await waitForAll([0]);
expect(root).toMatchRenderedOutput();
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
root.render();
- });
+ }
await waitForAll(['Suspend!']);
expect(root).toMatchRenderedOutput();
// Rendering again should suspend again.
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
root.render();
- });
+ }
await waitForAll(['Suspend!']);
});
@@ -747,25 +755,38 @@ describe('ReactHooksWithNoopRenderer', () => {
expect(root).toMatchRenderedOutput();
await act(async () => {
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ setLabel('B');
+ });
+ } else {
root.render();
setLabel('B');
- });
+ }
await waitForAll(['Suspend!']);
expect(root).toMatchRenderedOutput();
// Rendering again should suspend again.
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
root.render();
- });
+ }
await waitForAll(['Suspend!']);
// Flip the signal back to "cancel" the update. However, the update to
// label should still proceed. It shouldn't have been dropped.
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
root.render();
- });
+ }
await waitForAll(['B:0']);
expect(root).toMatchRenderedOutput();
});
diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
index bd4c534a781b8..0ebf8f2d53293 100644
--- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
@@ -1414,12 +1414,10 @@ describe('ReactLazy', () => {
// Swap the position of A and B
root.update();
- await waitForAll([
- 'Init B2',
- 'Loading...',
- 'Did unmount: A',
- 'Did unmount: B',
- ]);
+ await waitForAll(['Init B2', 'Loading...']);
+ jest.runAllTimers();
+
+ assertLog(['Did unmount: A', 'Did unmount: B']);
// The suspense boundary should've triggered now.
expect(root).toMatchRenderedOutput('Loading...');
@@ -1561,9 +1559,13 @@ describe('ReactLazy', () => {
expect(root).toMatchRenderedOutput('AB');
// Swap the position of A and B
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.update();
+ });
+ } else {
root.update();
- });
+ }
await waitForAll(['Init B2', 'Loading...']);
await resolveFakeImport(ChildB2);
// We need to flush to trigger the second one to load.
diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
index c73e3caf62c64..3a09abd9b10b7 100644
--- a/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactOffscreenSuspense-test.js
@@ -9,7 +9,6 @@ let useState;
let useEffect;
let startTransition;
let textCache;
-let waitFor;
let waitForPaint;
let assertLog;
@@ -29,7 +28,6 @@ describe('ReactOffscreen', () => {
startTransition = React.startTransition;
const InternalTestUtils = require('internal-test-utils');
- waitFor = InternalTestUtils.waitFor;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;
@@ -409,6 +407,7 @@ describe('ReactOffscreen', () => {
expect(root).toMatchRenderedOutput(B1);
});
+ // Only works in new reconciler
// @gate enableOffscreen
test('detect updates to a hidden tree during a concurrent event', async () => {
// This is a pretty complex test case. It relates to how we detect if an
@@ -443,17 +442,17 @@ describe('ReactOffscreen', () => {
setOuter = _setOuter;
return (
<>
+
+
+
-
-
-
}>
-
+
>
@@ -467,41 +466,50 @@ describe('ReactOffscreen', () => {
root.render();
});
assertLog([
- 'Inner: 0',
'Outer: 0',
- 'Sibling: 0',
+ 'Inner: 0',
+ 'Async: 0',
'Inner and outer are consistent',
]);
expect(root).toMatchRenderedOutput(
<>
- Inner: 0
Outer: 0
- Sibling: 0
+ Inner: 0
+ Async: 0
>,
);
await act(async () => {
// Update a value both inside and outside the hidden tree. These values
// must always be consistent.
- startTransition(() => {
- setOuter(1);
- setInner(1);
- // In the same render, also hide the offscreen tree.
- root.render();
- });
+ setOuter(1);
+ setInner(1);
+ // In the same render, also hide the offscreen tree.
+ root.render();
- await waitFor([
+ await waitForPaint([
// The outer update will commit, but the inner update is deferred until
// a later render.
'Outer: 1',
+
+ // Something suspended. This means we won't commit immediately; there
+ // will be an async gap between render and commit. In this test, we will
+ // use this property to schedule a concurrent update. The fact that
+ // we're using Suspense to schedule a concurrent update is not directly
+ // relevant to the test — we could also use time slicing, but I've
+ // chosen to use Suspense the because implementation details of time
+ // slicing are more volatile.
+ 'Suspend! [Async: 1]',
+
+ 'Loading...',
]);
// Assert that we haven't committed quite yet
expect(root).toMatchRenderedOutput(
<>
- Inner: 0
Outer: 0
- Sibling: 0
+ Inner: 0
+ Async: 0
>,
);
@@ -512,13 +520,14 @@ describe('ReactOffscreen', () => {
setInner(2);
});
- // Finish rendering and commit the in-progress render.
- await waitForPaint(['Sibling: 1']);
+ // Commit the previous render.
+ jest.runAllTimers();
expect(root).toMatchRenderedOutput(
<>
- Inner: 0
Outer: 1
- Sibling: 1
+ Inner: 0
+ Async: 0
+ Loading...
>,
);
@@ -527,27 +536,32 @@ describe('ReactOffscreen', () => {
root.render();
});
assertLog([
+ 'Outer: 1',
+
// There are two pending updates on Inner, but only the first one
// is processed, even though they share the same lane. If the second
// update were erroneously processed, then Inner would be inconsistent
// with Outer.
'Inner: 1',
- 'Outer: 1',
- 'Sibling: 1',
+
+ 'Suspend! [Async: 1]',
+ 'Loading...',
'Inner and outer are consistent',
]);
});
assertLog([
- 'Inner: 2',
'Outer: 2',
- 'Sibling: 2',
+ 'Inner: 2',
+ 'Suspend! [Async: 2]',
+ 'Loading...',
'Inner and outer are consistent',
]);
expect(root).toMatchRenderedOutput(
<>
- Inner: 2
Outer: 2
- Sibling: 2
+ Inner: 2
+ Async: 0
+ Loading...
>,
);
});
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
index 75827aa409368..5723e0039e2eb 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
@@ -125,9 +125,13 @@ describe('ReactSuspense', () => {
// Navigate the shell to now render the child content.
// This should suspend.
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.update();
+ });
+ } else {
root.update();
- });
+ }
await waitForAll([
'Foo',
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js
index 5200635b6fca4..a684f6ab4d245 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js
@@ -576,7 +576,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be destroyed and recreated for function components', async () => {
function App({children = null}) {
Scheduler.log('App render');
@@ -642,6 +642,19 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+
+ >,
+ );
+
+ await jest.runAllTimers();
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ assertLog([
'Text:Inside:Before destroy layout',
'Text:Inside:After destroy layout',
'Text:Fallback create layout',
@@ -698,7 +711,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be destroyed and recreated for class components', async () => {
class ClassText extends React.Component {
componentDidMount() {
@@ -783,6 +796,19 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Async',
'ClassText:Fallback render',
'ClassText:Outside render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+
+ >,
+ );
+
+ await jest.runAllTimers();
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ assertLog([
'ClassText:Inside:Before componentWillUnmount',
'ClassText:Inside:After componentWillUnmount',
'ClassText:Fallback componentDidMount',
@@ -834,7 +860,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be destroyed and recreated when nested below host components', async () => {
function App({children = null}) {
Scheduler.log('App render');
@@ -888,10 +914,17 @@ describe('ReactSuspenseEffectsSemantics', () => {
,
);
- await waitFor([
- 'App render',
- 'Suspend:Async',
- 'Text:Fallback render',
+ await waitFor(['App render', 'Suspend:Async', 'Text:Fallback render']);
+ expect(ReactNoop).toMatchRenderedOutput(
+
+
+ ,
+ );
+
+ await jest.runAllTimers();
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ assertLog([
'Text:Outer destroy layout',
'Text:Inner destroy layout',
'Text:Fallback create layout',
@@ -946,7 +979,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be destroyed and recreated even if there is a bailout because of memoization', async () => {
const MemoizedText = React.memo(Text, () => true);
@@ -1007,6 +1040,18 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Async',
// Text:MemoizedInner is memoized
'Text:Fallback render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+
+
+ ,
+ );
+
+ await jest.runAllTimers();
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ // Even though the innermost layout effects are beneath a hidden HostComponent.
+ assertLog([
'Text:Outer destroy layout',
'Text:MemoizedInner destroy layout',
'Text:Fallback create layout',
@@ -1403,7 +1448,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be cleaned up inside of a fallback that suspends', async () => {
function App({fallbackChildren = null, outerChildren = null}) {
return (
@@ -1456,6 +1501,17 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Fallback:Inside render',
'Text:Fallback:Outside render',
'Text:Outside render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ await jest.runAllTimers();
+ assertLog([
'Text:Inside destroy layout',
'Text:Fallback:Inside create layout',
'Text:Fallback:Outside create layout',
@@ -1490,6 +1546,19 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Fallback:Fallback render',
'Text:Fallback:Outside render',
'Text:Outside render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+
+
+ >,
+ );
+
+ // Timing out should commit the inner fallback and destroy outer fallback layout effects.
+ await jest.runAllTimers();
+ assertLog([
'Text:Fallback:Inside destroy layout',
'Text:Fallback:Fallback create layout',
]);
@@ -1655,7 +1724,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be cleaned up deeper inside of a subtree that suspends', async () => {
function ConditionalSuspense({shouldSuspend}) {
if (shouldSuspend) {
@@ -1702,6 +1771,17 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Suspend',
'Text:Fallback render',
'Text:Outside render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+
+ // Timing out should commit the inner fallback and destroy outer fallback layout effects.
+ await jest.runAllTimers();
+ assertLog([
'Text:Inside destroy layout',
'Text:Fallback create layout',
]);
@@ -2225,7 +2305,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
});
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be only destroy layout effects once if a tree suspends in multiple places', async () => {
class ClassText extends React.Component {
componentDidMount() {
@@ -2286,6 +2366,18 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Function render',
'Suspend:Async_1',
'ClassText:Fallback render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+
+ await jest.runAllTimers();
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ assertLog([
'Text:Function destroy layout',
'ClassText:Class componentWillUnmount',
'ClassText:Fallback componentDidMount',
@@ -2356,7 +2448,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
- // @gate enableLegacyCache
+ // @gate enableLegacyCache && enableSyncDefaultUpdates
it('should be only destroy layout effects once if a component suspends multiple times', async () => {
class ClassText extends React.Component {
componentDidMount() {
@@ -2426,6 +2518,19 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspender "A" render',
'Suspend:A',
'ClassText:Fallback render',
+ ]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+
+ >,
+ );
+
+ await jest.runAllTimers();
+
+ // Timing out should commit the fallback and destroy inner layout effects.
+ assertLog([
'Text:Function destroy layout',
'ClassText:Class componentWillUnmount',
'ClassText:Fallback componentDidMount',
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
index b9bb57b58a0e6..656bd5cacf38b 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
@@ -488,12 +488,13 @@ describe('ReactSuspensePlaceholder', () => {
'Suspend! [Loaded]',
'Fallback',
]);
+ expect(ReactNoop).toMatchRenderedOutput('Text');
+
// Show the fallback UI.
+ jest.advanceTimersByTime(900);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(2);
- jest.advanceTimersByTime(900);
-
// The suspense update should only show the "Loading..." Fallback.
// The actual duration should include 10ms spent rendering Fallback,
// plus the 3ms render all of the partially rendered suspended subtree.
@@ -528,19 +529,19 @@ describe('ReactSuspensePlaceholder', () => {
'Suspend! [Sibling]',
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
- expect(onRender).toHaveBeenCalledTimes(3);
+ expect(onRender).toHaveBeenCalledTimes(2);
// Resolve the pending promise.
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [Loaded]', 'Promise resolved [Sibling]']);
- await waitForAll(['Suspending', 'Loaded', 'New', 'Sibling']);
- expect(onRender).toHaveBeenCalledTimes(4);
+ await waitForAll(['App', 'Suspending', 'Loaded', 'New', 'Sibling']);
+ expect(onRender).toHaveBeenCalledTimes(3);
// When the suspending data is resolved and our final UI is rendered,
// both times should include the 6ms rendering Text,
// the 2ms rendering Suspending, and the 1ms rendering AsyncText.
- expect(onRender.mock.calls[3][2]).toBe(9);
- expect(onRender.mock.calls[3][3]).toBe(9);
+ expect(onRender.mock.calls[2][2]).toBe(9);
+ expect(onRender.mock.calls[2][3]).toBe(9);
});
});
});
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
index cabb198f2e459..a901a571a1525 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
@@ -285,9 +285,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await waitForAll(['Foo']);
// The update will suspend.
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render();
+ });
+ } else {
ReactNoop.render();
- });
+ }
await waitForAll([
'Foo',
'Bar',
@@ -361,11 +365,22 @@ describe('ReactSuspenseWithNoopRenderer', () => {
});
// @gate enableLegacyCache
- it('when something suspends, unwinds immediately without rendering siblings', async () => {
+ it('continues rendering siblings after suspending', async () => {
// A shell is needed. The update cause it to suspend.
ReactNoop.render(} />);
await waitForAll([]);
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render(
+ }>
+
+
+
+
+ ,
+ );
+ });
+ } else {
ReactNoop.render(
}>
@@ -374,8 +389,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
,
);
- });
-
+ }
// B suspends. Render a fallback
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
// Did not commit yet.
@@ -433,9 +447,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(null);
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render();
+ });
+ } else {
ReactNoop.render();
- });
+ }
await waitForAll(['Suspend! [Result]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
@@ -579,6 +597,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
// @gate enableLegacyCache
it('keeps working on lower priority work after being pinged', async () => {
+ // Advance the virtual time so that we're close to the edge of a bucket.
+ ReactNoop.expire(149);
+
function App(props) {
return (
}>
@@ -592,15 +613,26 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(null);
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render();
+ });
+ } else {
ReactNoop.render();
- });
+ }
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
- React.startTransition(() => {
+ // Advance React's virtual time by enough to fall into a new async bucket,
+ // but not enough to expire the suspense timeout.
+ ReactNoop.expire(120);
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render();
+ });
+ } else {
ReactNoop.render();
- });
+ }
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
@@ -721,6 +753,61 @@ describe('ReactSuspenseWithNoopRenderer', () => {
assertLog(['Sibling', 'Step 4']);
});
+ // @gate enableLegacyCache
+ it('forces an expiration after an update times out', async () => {
+ ReactNoop.render(
+
+ } />
+ ,
+ );
+ await waitForAll([]);
+
+ ReactNoop.render(
+
+ }>
+
+
+
+ ,
+ );
+
+ await waitForAll([
+ // The async child suspends
+ 'Suspend! [Async]',
+ // Render the placeholder
+ 'Loading...',
+ // Continue on the sibling
+ 'Sync',
+ ]);
+ // The update hasn't expired yet, so we commit nothing.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Advance both React's virtual time and Jest's timers by enough to expire
+ // the update.
+ ReactNoop.expire(10000);
+ await advanceTimers(10000);
+ // No additional rendering work is required, since we already prepared
+ // the placeholder.
+ assertLog([]);
+ // Should have committed the placeholder.
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+
+ // Once the promise resolves, we render the suspended view
+ await resolveText('Async');
+ await waitForAll(['Async']);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+ });
+
// @gate enableLegacyCache
it('switches to an inner fallback after suspending for a while', async () => {
// Advance the virtual time so that we're closer to the edge of a bucket.
@@ -856,6 +943,109 @@ describe('ReactSuspenseWithNoopRenderer', () => {
expect(ReactNoop).toMatchRenderedOutput();
});
+ // @gate enableLegacyCache
+ it('expires early by default', async () => {
+ ReactNoop.render(
+
+ } />
+ ,
+ );
+ await waitForAll([]);
+
+ ReactNoop.render(
+
+ }>
+
+
+
+ ,
+ );
+
+ await waitForAll([
+ // The async child suspends
+ 'Suspend! [Async]',
+ 'Loading...',
+ // Continue on the sibling
+ 'Sync',
+ ]);
+ // The update hasn't expired yet, so we commit nothing.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Advance both React's virtual time and Jest's timers by enough to trigger
+ // the timeout, but not by enough to flush the promise or reach the true
+ // expiration time.
+ ReactNoop.expire(2000);
+ await advanceTimers(2000);
+ await waitForAll([]);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+
+ // Once the promise resolves, we render the suspended view
+ await resolveText('Async');
+ await waitForAll(['Async']);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+ });
+
+ // @gate enableLegacyCache
+ it('does not expire for transitions', async () => {
+ ReactNoop.render(
+
+ } />
+ ,
+ );
+ await waitForAll([]);
+
+ React.startTransition(() => {
+ ReactNoop.render(
+
+ }>
+
+
+
+ ,
+ );
+ });
+
+ await waitForAll([
+ // The async child suspends
+ 'Suspend! [Async]',
+ 'Loading...',
+ // Continue on the sibling
+ 'Sync',
+ ]);
+ // The update hasn't expired yet, so we commit nothing.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Advance both React's virtual time and Jest's timers,
+ // but not by enough to flush the promise or reach the true expiration time.
+ ReactNoop.expire(2000);
+ await advanceTimers(2000);
+ // Even flushing won't yield a fallback in a transition.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ await waitForAll([]);
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Once the promise resolves, we render the suspended view
+ await resolveText('Async');
+ await waitForAll(['Async', 'Sync']);
+ expect(ReactNoop).toMatchRenderedOutput(
+ <>
+
+
+ >,
+ );
+ });
+
// @gate enableLegacyCache
it('resolves successfully even if fallback render is pending', async () => {
const root = ReactNoop.createRoot();
@@ -949,13 +1139,21 @@ describe('ReactSuspenseWithNoopRenderer', () => {
ReactNoop.render(} />);
await waitForAll([]);
- React.startTransition(() => {
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render(
+ }>
+
+ ,
+ );
+ });
+ } else {
ReactNoop.render(
}>
,
);
- });
+ }
await waitForAll(['Suspend! [Async]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
@@ -1722,6 +1920,74 @@ describe('ReactSuspenseWithNoopRenderer', () => {
]);
});
+ // @gate enableLegacyCache
+ it('suspends for longer if something took a long (CPU bound) time to render', async () => {
+ function Foo({renderContent}) {
+ Scheduler.log('Foo');
+ return (
+ }>
+ {renderContent ? : null}
+
+ );
+ }
+
+ ReactNoop.render();
+ await waitForAll(['Foo']);
+
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render();
+ });
+ } else {
+ ReactNoop.render();
+ }
+ Scheduler.unstable_advanceTime(100);
+ await advanceTimers(100);
+ // Start rendering
+ await waitFor(['Foo']);
+ // For some reason it took a long time to render Foo.
+ Scheduler.unstable_advanceTime(1250);
+ await advanceTimers(1250);
+ await waitForAll([
+ // A suspends
+ 'Suspend! [A]',
+ 'Loading...',
+ ]);
+ // We're now suspended and we haven't shown anything yet.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Flush some of the time
+ Scheduler.unstable_advanceTime(450);
+ await advanceTimers(450);
+ // Because we've already been waiting for so long we can
+ // wait a bit longer. Still nothing...
+ await waitForAll([]);
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Eventually we'll show the fallback.
+ Scheduler.unstable_advanceTime(500);
+ await advanceTimers(500);
+ // No need to rerender.
+ await waitForAll([]);
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ // Since this is a transition, we never fallback.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+ } else {
+ expect(ReactNoop).toMatchRenderedOutput();
+ }
+
+ // Flush the promise completely
+ await resolveText('A');
+ // Renders successfully
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ // TODO: Why does this render Foo
+ await waitForAll(['Foo', 'A']);
+ } else {
+ await waitForAll(['A']);
+ }
+ expect(ReactNoop).toMatchRenderedOutput();
+ });
+
// @gate enableLegacyCache
it('does not suspends if a fallback has been shown for a long time', async () => {
function Foo() {
@@ -1850,6 +2116,59 @@ describe('ReactSuspenseWithNoopRenderer', () => {
);
});
+ // @gate enableLegacyCache
+ it('does not suspend for very long after a higher priority update', async () => {
+ function Foo({renderContent}) {
+ Scheduler.log('Foo');
+ return (
+ }>
+ {renderContent ? : null}
+
+ );
+ }
+
+ ReactNoop.render();
+ await waitForAll(['Foo']);
+
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ ReactNoop.render();
+ });
+ } else {
+ ReactNoop.render();
+ }
+ await waitFor(['Foo']);
+
+ // Advance some time.
+ Scheduler.unstable_advanceTime(100);
+ await advanceTimers(100);
+
+ await waitForAll([
+ // A suspends
+ 'Suspend! [A]',
+ 'Loading...',
+ ]);
+
+ // We're now suspended and we haven't shown anything yet.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Flush some of the time
+ Scheduler.unstable_advanceTime(500);
+ jest.advanceTimersByTime(500);
+
+ // We should have already shown the fallback.
+ // When we wrote this test, we inferred the start time of high priority
+ // updates as way earlier in the past. This test ensures that we don't
+ // use this assumption to add a very long JND.
+ await waitForAll([]);
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ // Transitions never fallback.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+ } else {
+ expect(ReactNoop).toMatchRenderedOutput();
+ }
+ });
+
// TODO: flip to "warns" when this is implemented again.
// @gate enableLegacyCache
it('does not warn when a low priority update suspends inside a high priority update for functional components', async () => {
@@ -2208,6 +2527,12 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
+ // Still suspended.
+ expect(ReactNoop).toMatchRenderedOutput();
+
+ // Flush to skip suspended time.
+ Scheduler.unstable_advanceTime(600);
+ await advanceTimers(600);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
// Transitions never fall back.
@@ -2273,6 +2598,54 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
});
+ // @gate enableLegacyCache
+ it('commits a suspended idle pri render within a reasonable time', async () => {
+ function Foo({renderContent}) {
+ return (
+
+ }>
+ {renderContent ? : null}
+
+
+ );
+ }
+
+ ReactNoop.render();
+ await waitForAll([]);
+
+ ReactNoop.render();
+
+ // Took a long time to render. This is to ensure we get a long suspense time.
+ // Could also use something like startTransition to simulate this.
+ Scheduler.unstable_advanceTime(1500);
+ await advanceTimers(1500);
+
+ await waitForAll(['Suspend! [A]', 'Loading A...']);
+ // We're still suspended.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Schedule an update at idle pri.
+ ReactNoop.idleUpdates(() => ReactNoop.render());
+ // We won't even work on Idle priority.
+ await waitForAll([]);
+
+ // We're still suspended.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Advance time a little bit.
+ Scheduler.unstable_advanceTime(150);
+ await advanceTimers(150);
+
+ // We should not have committed yet because we had a long suspense time.
+ expect(ReactNoop).toMatchRenderedOutput(null);
+
+ // Flush to skip suspended time.
+ Scheduler.unstable_advanceTime(600);
+ await advanceTimers(600);
+
+ expect(ReactNoop).toMatchRenderedOutput();
+ });
+
describe('startTransition', () => {
// @gate enableLegacyCache
it('top level render', async () => {
@@ -2729,6 +3102,62 @@ describe('ReactSuspenseWithNoopRenderer', () => {
);
});
+ // TODO: This test is specifically about avoided commits that suspend for a
+ // JND. We may remove this behavior.
+ // @gate enableLegacyCache
+ it("suspended commit remains suspended even if there's another update at same expiration", async () => {
+ // Regression test
+ function App({text}) {
+ return (
+
+
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+ await act(() => {
+ root.render();
+ });
+ assertLog(['Suspend! [Initial]']);
+
+ // Resolve initial render
+ await act(async () => {
+ await resolveText('Initial');
+ });
+ assertLog(['Initial']);
+ expect(root).toMatchRenderedOutput();
+
+ await act(async () => {
+ // Update. Since showing a fallback would hide content that's already
+ // visible, it should suspend for a JND without committing.
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
+ root.render();
+ }
+ await waitForAll(['Suspend! [First update]']);
+
+ // Should not display a fallback
+ expect(root).toMatchRenderedOutput();
+
+ // Update again. This should also suspend for a JND.
+ if (gate(flags => flags.enableSyncDefaultUpdates)) {
+ React.startTransition(() => {
+ root.render();
+ });
+ } else {
+ root.render();
+ }
+ await waitForAll(['Suspend! [Second update]']);
+
+ // Should not display a fallback
+ expect(root).toMatchRenderedOutput();
+ });
+ });
+
it('regression test: resets current "debug phase" after suspending', async () => {
function App() {
return (
@@ -2990,8 +3419,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
setText('C');
});
- // First we attempt the high pri update. It suspends.
- await waitForPaint(['Suspend! [B]', 'Loading...']);
+ await waitForAll([
+ // First we attempt the high pri update. It suspends.
+ 'Suspend! [B]',
+ 'Loading...',
+ ]);
+
+ // Commit the placeholder to unblock the Idle update.
+ await advanceTimers(250);
expect(root).toMatchRenderedOutput(
<>
@@ -3558,8 +3993,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
]);
expect(root).toMatchRenderedOutput(
<>
-
-
+
>,
);
@@ -3723,41 +4157,25 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await act(async () => {
setText('B');
ReactNoop.idleUpdates(() => {
- setText('C');
+ setText('B');
});
+ // Suspend the first update. The second update doesn't run because it has
+ // Idle priority.
+ await waitForAll(['Suspend! [B]', 'Loading...']);
- // Suspend the first update. This triggers an immediate fallback because
- // it wasn't wrapped in startTransition.
- await waitForPaint(['Suspend! [B]', 'Loading...']);
- expect(root).toMatchRenderedOutput(
- <>
-
-
- >,
- );
+ // Commit the fallback. Now we'll try working on Idle.
+ jest.runAllTimers();
- // Once the fallback renders, proceed to the Idle update. This will
- // also suspend.
- await waitForAll(['Suspend! [C]']);
+ // It also suspends.
+ await waitForAll(['Suspend! [B]']);
});
- // Finish loading B.
await act(async () => {
setText('B');
await resolveText('B');
});
- // We did not try to render the Idle update again because there have been no
- // additional updates since the last time it was attempted.
assertLog(['B']);
expect(root).toMatchRenderedOutput();
-
- // Finish loading C.
- await act(async () => {
- setText('C');
- await resolveText('C');
- });
- assertLog(['C']);
- expect(root).toMatchRenderedOutput();
});
// @gate enableLegacyCache