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.
This commit is contained in:
Andrew Clark 2023-04-11 00:19:49 -04:00 committed by GitHub
parent ac43bf6870
commit 0b931f90e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 114 additions and 743 deletions

View File

@ -236,7 +236,7 @@ describe('ReactCache', () => {
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [4]']);
await waitForAll([1, 4, 'Suspend! [5]', 'Loading...']);
await waitForAll([1, 4, 'Suspend! [5]']);
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]', 'Loading...']);
await waitForAll([1, 2, 'Suspend! [3]']);
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [3]']);

View File

@ -145,7 +145,6 @@ import {
includesExpiredLane,
getNextLanes,
getLanesToRetrySynchronouslyOnError,
getMostRecentEventTime,
markRootUpdated,
markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
markRootPinged,
@ -284,8 +283,6 @@ import {
} from './ReactFiberRootScheduler';
import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext';
const ceil = Math.ceil;
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
const {
@ -1193,38 +1190,6 @@ 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,
@ -3580,31 +3545,6 @@ 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;

View File

@ -732,13 +732,9 @@ describe('ReactExpiration', () => {
expect(root).toMatchRenderedOutput('A0BC');
await act(async () => {
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<App step={1} />);
});
} else {
React.startTransition(() => {
root.render(<App step={1} />);
}
});
await waitForAll(['Suspend! [A1]', 'Loading...']);
// Lots of time elapses before the promise resolves

View File

@ -692,24 +692,16 @@ describe('ReactHooksWithNoopRenderer', () => {
await waitForAll([0]);
expect(root).toMatchRenderedOutput(<span prop={0} />);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<Foo signal={false} />);
});
} else {
React.startTransition(() => {
root.render(<Foo signal={false} />);
}
});
await waitForAll(['Suspend!']);
expect(root).toMatchRenderedOutput(<span prop={0} />);
// Rendering again should suspend again.
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<Foo signal={false} />);
});
} else {
React.startTransition(() => {
root.render(<Foo signal={false} />);
}
});
await waitForAll(['Suspend!']);
});
@ -755,38 +747,25 @@ describe('ReactHooksWithNoopRenderer', () => {
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
await act(async () => {
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<Foo signal={false} />);
setLabel('B');
});
} else {
React.startTransition(() => {
root.render(<Foo signal={false} />);
setLabel('B');
}
});
await waitForAll(['Suspend!']);
expect(root).toMatchRenderedOutput(<span prop="A:0" />);
// Rendering again should suspend again.
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<Foo signal={false} />);
});
} else {
React.startTransition(() => {
root.render(<Foo signal={false} />);
}
});
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.
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<Foo signal={true} />);
});
} else {
React.startTransition(() => {
root.render(<Foo signal={true} />);
}
});
await waitForAll(['B:0']);
expect(root).toMatchRenderedOutput(<span prop="B:0" />);
});

View File

@ -1414,10 +1414,12 @@ describe('ReactLazy', () => {
// Swap the position of A and B
root.update(<Parent swap={true} />);
await waitForAll(['Init B2', 'Loading...']);
jest.runAllTimers();
assertLog(['Did unmount: A', 'Did unmount: B']);
await waitForAll([
'Init B2',
'Loading...',
'Did unmount: A',
'Did unmount: B',
]);
// The suspense boundary should've triggered now.
expect(root).toMatchRenderedOutput('Loading...');
@ -1559,13 +1561,9 @@ describe('ReactLazy', () => {
expect(root).toMatchRenderedOutput('AB');
// Swap the position of A and B
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.update(<Parent swap={true} />);
});
} else {
React.startTransition(() => {
root.update(<Parent swap={true} />);
}
});
await waitForAll(['Init B2', 'Loading...']);
await resolveFakeImport(ChildB2);
// We need to flush to trigger the second one to load.

View File

@ -9,6 +9,7 @@ let useState;
let useEffect;
let startTransition;
let textCache;
let waitFor;
let waitForPaint;
let assertLog;
@ -28,6 +29,7 @@ describe('ReactOffscreen', () => {
startTransition = React.startTransition;
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;
@ -407,7 +409,6 @@ describe('ReactOffscreen', () => {
expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
});
// 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
@ -442,17 +443,17 @@ describe('ReactOffscreen', () => {
setOuter = _setOuter;
return (
<>
<span>
<Text text={'Outer: ' + outer} />
</span>
<Offscreen mode={show ? 'visible' : 'hidden'}>
<span>
<Child outer={outer} />
</span>
</Offscreen>
<span>
<Text text={'Outer: ' + outer} />
</span>
<Suspense fallback={<Text text="Loading..." />}>
<span>
<AsyncText text={'Async: ' + outer} />
<Text text={'Sibling: ' + outer} />
</span>
</Suspense>
</>
@ -466,50 +467,41 @@ describe('ReactOffscreen', () => {
root.render(<App show={true} />);
});
assertLog([
'Outer: 0',
'Inner: 0',
'Async: 0',
'Outer: 0',
'Sibling: 0',
'Inner and outer are consistent',
]);
expect(root).toMatchRenderedOutput(
<>
<span>Outer: 0</span>
<span>Inner: 0</span>
<span>Async: 0</span>
<span>Outer: 0</span>
<span>Sibling: 0</span>
</>,
);
await act(async () => {
// Update a value both inside and outside the hidden tree. These values
// must always be consistent.
setOuter(1);
setInner(1);
// In the same render, also hide the offscreen tree.
root.render(<App show={false} />);
startTransition(() => {
setOuter(1);
setInner(1);
// In the same render, also hide the offscreen tree.
root.render(<App show={false} />);
});
await waitForPaint([
await waitFor([
// 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(
<>
<span>Outer: 0</span>
<span>Inner: 0</span>
<span>Async: 0</span>
<span>Outer: 0</span>
<span>Sibling: 0</span>
</>,
);
@ -520,14 +512,13 @@ describe('ReactOffscreen', () => {
setInner(2);
});
// Commit the previous render.
jest.runAllTimers();
// Finish rendering and commit the in-progress render.
await waitForPaint(['Sibling: 1']);
expect(root).toMatchRenderedOutput(
<>
<span>Outer: 1</span>
<span hidden={true}>Inner: 0</span>
<span hidden={true}>Async: 0</span>
Loading...
<span>Outer: 1</span>
<span>Sibling: 1</span>
</>,
);
@ -536,32 +527,27 @@ describe('ReactOffscreen', () => {
root.render(<App show={true} />);
});
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',
'Suspend! [Async: 1]',
'Loading...',
'Outer: 1',
'Sibling: 1',
'Inner and outer are consistent',
]);
});
assertLog([
'Outer: 2',
'Inner: 2',
'Suspend! [Async: 2]',
'Loading...',
'Outer: 2',
'Sibling: 2',
'Inner and outer are consistent',
]);
expect(root).toMatchRenderedOutput(
<>
<span>Outer: 2</span>
<span>Inner: 2</span>
<span hidden={true}>Async: 0</span>
Loading...
<span>Outer: 2</span>
<span>Sibling: 2</span>
</>,
);
});

View File

@ -125,13 +125,9 @@ describe('ReactSuspense', () => {
// Navigate the shell to now render the child content.
// This should suspend.
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.update(<Foo renderBar={true} />);
});
} else {
React.startTransition(() => {
root.update(<Foo renderBar={true} />);
}
});
await waitForAll([
'Foo',

View File

@ -576,7 +576,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be destroyed and recreated for function components', async () => {
function App({children = null}) {
Scheduler.log('App render');
@ -642,19 +642,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
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',
@ -711,7 +698,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be destroyed and recreated for class components', async () => {
class ClassText extends React.Component {
componentDidMount() {
@ -796,19 +783,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Async',
'ClassText:Fallback render',
'ClassText:Outside render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
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',
@ -860,7 +834,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be destroyed and recreated when nested below host components', async () => {
function App({children = null}) {
Scheduler.log('App render');
@ -914,17 +888,10 @@ describe('ReactSuspenseEffectsSemantics', () => {
<AsyncText text="Async" ms={1000} />
</App>,
);
await waitFor(['App render', 'Suspend:Async', 'Text:Fallback render']);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Outer">
<span prop="Inner" />
</span>,
);
await jest.runAllTimers();
// Timing out should commit the fallback and destroy inner layout effects.
assertLog([
await waitFor([
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outer destroy layout',
'Text:Inner destroy layout',
'Text:Fallback create layout',
@ -979,7 +946,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be destroyed and recreated even if there is a bailout because of memoization', async () => {
const MemoizedText = React.memo(Text, () => true);
@ -1040,18 +1007,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Async',
// Text:MemoizedInner is memoized
'Text:Fallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Outer">
<span prop="MemoizedInner" />
</span>,
);
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',
@ -1448,7 +1403,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be cleaned up inside of a fallback that suspends', async () => {
function App({fallbackChildren = null, outerChildren = null}) {
return (
@ -1501,17 +1456,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Fallback:Inside render',
'Text:Fallback:Outside render',
'Text:Outside render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// 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',
@ -1546,19 +1490,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Fallback:Fallback render',
'Text:Fallback:Outside render',
'Text:Outside render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" hidden={true} />
<span prop="Fallback:Inside" />
<span prop="Fallback:Outside" />
<span prop="Outside" />
</>,
);
// 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',
]);
@ -1724,7 +1655,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be cleaned up deeper inside of a subtree that suspends', async () => {
function ConditionalSuspense({shouldSuspend}) {
if (shouldSuspend) {
@ -1771,17 +1702,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspend:Suspend',
'Text:Fallback render',
'Text:Outside render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// 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',
]);
@ -2305,7 +2225,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
});
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be only destroy layout effects once if a tree suspends in multiple places', async () => {
class ClassText extends React.Component {
componentDidMount() {
@ -2366,18 +2286,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Text:Function render',
'Suspend:Async_1',
'ClassText:Fallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" />
<span prop="Class" />
</>,
);
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',
@ -2448,7 +2356,7 @@ describe('ReactSuspenseEffectsSemantics', () => {
]);
});
// @gate enableLegacyCache && enableSyncDefaultUpdates
// @gate enableLegacyCache
it('should be only destroy layout effects once if a component suspends multiple times', async () => {
class ClassText extends React.Component {
componentDidMount() {
@ -2518,19 +2426,6 @@ describe('ReactSuspenseEffectsSemantics', () => {
'Suspender "A" render',
'Suspend:A',
'ClassText:Fallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" />
<span prop="Suspender" />
<span prop="Class" />
</>,
);
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',

View File

@ -488,13 +488,12 @@ 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.
@ -529,19 +528,19 @@ describe('ReactSuspensePlaceholder', () => {
'Suspend! [Sibling]',
]);
expect(ReactNoop).toMatchRenderedOutput('Loading...');
expect(onRender).toHaveBeenCalledTimes(2);
expect(onRender).toHaveBeenCalledTimes(3);
// Resolve the pending promise.
jest.advanceTimersByTime(100);
assertLog(['Promise resolved [Loaded]', 'Promise resolved [Sibling]']);
await waitForAll(['App', 'Suspending', 'Loaded', 'New', 'Sibling']);
expect(onRender).toHaveBeenCalledTimes(3);
await waitForAll(['Suspending', 'Loaded', 'New', 'Sibling']);
expect(onRender).toHaveBeenCalledTimes(4);
// 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[2][2]).toBe(9);
expect(onRender.mock.calls[2][3]).toBe(9);
expect(onRender.mock.calls[3][2]).toBe(9);
expect(onRender.mock.calls[3][3]).toBe(9);
});
});
});

View File

@ -289,13 +289,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await waitForAll(['Foo']);
// The update will suspend.
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(<Foo renderBar={true} />);
});
} else {
React.startTransition(() => {
ReactNoop.render(<Foo renderBar={true} />);
}
});
await waitForAll([
'Foo',
'Bar',
@ -371,22 +367,11 @@ describe('ReactSuspenseWithNoopRenderer', () => {
});
// @gate enableLegacyCache
it('continues rendering siblings after suspending', async () => {
it('when something suspends, unwinds immediately without rendering siblings', async () => {
// A shell is needed. The update cause it to suspend.
ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
await waitForAll([]);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<Text text="A" />
<AsyncText text="B" />
<Text text="C" />
<Text text="D" />
</Suspense>,
);
});
} else {
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<Text text="A" />
@ -395,7 +380,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
<Text text="D" />
</Suspense>,
);
}
});
// B suspends. Render a fallback
await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
// Did not commit yet.
@ -453,13 +439,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(null);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(<App renderContent={true} />);
});
} else {
React.startTransition(() => {
ReactNoop.render(<App renderContent={true} />);
}
});
await waitForAll(['Suspend! [Result]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
@ -588,9 +570,6 @@ 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 (
<Suspense fallback={<Text text="Loading..." />}>
@ -604,26 +583,15 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await waitForAll([]);
expect(ReactNoop).toMatchRenderedOutput(null);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(<App showA={true} showB={false} />);
});
} else {
React.startTransition(() => {
ReactNoop.render(<App showA={true} showB={false} />);
}
});
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
// 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(<App showA={true} showB={true} />);
});
} else {
React.startTransition(() => {
ReactNoop.render(<App showA={true} showB={true} />);
}
});
await waitForAll(['Suspend! [A]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
@ -744,61 +712,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
assertLog(['Sibling', 'Step 4']);
});
// @gate enableLegacyCache
it('forces an expiration after an update times out', async () => {
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />} />
</Fragment>,
);
await waitForAll([]);
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="Sync" />
</Fragment>,
);
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(
<>
<span prop="Loading..." />
<span prop="Sync" />
</>,
);
// Once the promise resolves, we render the suspended view
await resolveText('Async');
await waitForAll(['Async']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Sync" />
</>,
);
});
// @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.
@ -934,109 +847,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
expect(ReactNoop).toMatchRenderedOutput(<span prop="Loading (outer)..." />);
});
// @gate enableLegacyCache
it('expires early by default', async () => {
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />} />
</Fragment>,
);
await waitForAll([]);
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="Sync" />
</Fragment>,
);
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(
<>
<span prop="Loading..." />
<span prop="Sync" />
</>,
);
// Once the promise resolves, we render the suspended view
await resolveText('Async');
await waitForAll(['Async']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Sync" />
</>,
);
});
// @gate enableLegacyCache
it('does not expire for transitions', async () => {
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />} />
</Fragment>,
);
await waitForAll([]);
React.startTransition(() => {
ReactNoop.render(
<Fragment>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>
<Text text="Sync" />
</Fragment>,
);
});
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(
<>
<span prop="Async" />
<span prop="Sync" />
</>,
);
});
// @gate enableLegacyCache
it('resolves successfully even if fallback render is pending', async () => {
const root = ReactNoop.createRoot();
@ -1129,21 +939,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
ReactNoop.render(<Suspense fallback={<Text text="Loading..." />} />);
await waitForAll([]);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>,
);
});
} else {
React.startTransition(() => {
ReactNoop.render(
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Async" />
</Suspense>,
);
}
});
await waitForAll(['Suspend! [Async]', 'Loading...']);
expect(ReactNoop).toMatchRenderedOutput(null);
@ -1910,74 +1712,6 @@ 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 (
<Suspense fallback={<Text text="Loading..." />}>
{renderContent ? <AsyncText text="A" /> : null}
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo']);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(<Foo renderContent={true} />);
});
} else {
ReactNoop.render(<Foo renderContent={true} />);
}
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(<span prop="Loading..." />);
}
// 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(<span prop="A" />);
});
// @gate enableLegacyCache
it('does not suspends if a fallback has been shown for a long time', async () => {
function Foo() {
@ -2088,59 +1822,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
);
});
// @gate enableLegacyCache
it('does not suspend for very long after a higher priority update', async () => {
function Foo({renderContent}) {
Scheduler.log('Foo');
return (
<Suspense fallback={<Text text="Loading..." />}>
{renderContent ? <AsyncText text="A" /> : null}
</Suspense>
);
}
ReactNoop.render(<Foo />);
await waitForAll(['Foo']);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
ReactNoop.render(<Foo renderContent={true} />);
});
} else {
ReactNoop.render(<Foo renderContent={true} />);
}
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(<span prop="Loading..." />);
}
});
// 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 () => {
@ -2499,12 +2180,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
await waitForAll(['Foo', 'A', 'Suspend! [B]', 'Loading B...']);
// Still suspended.
expect(ReactNoop).toMatchRenderedOutput(<span prop="A" />);
// Flush to skip suspended time.
Scheduler.unstable_advanceTime(600);
await advanceTimers(600);
if (gate(flags => flags.enableSyncDefaultUpdates)) {
// Transitions never fall back.
@ -2570,54 +2245,6 @@ describe('ReactSuspenseWithNoopRenderer', () => {
}
});
// @gate enableLegacyCache
it('commits a suspended idle pri render within a reasonable time', async () => {
function Foo({renderContent}) {
return (
<Fragment>
<Suspense fallback={<Text text="Loading A..." />}>
{renderContent ? <AsyncText text="A" /> : null}
</Suspense>
</Fragment>
);
}
ReactNoop.render(<Foo />);
await waitForAll([]);
ReactNoop.render(<Foo renderContent={1} />);
// 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(<Foo renderContent={2} />));
// 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(<span prop="Loading A..." />);
});
describe('startTransition', () => {
// @gate enableLegacyCache
it('top level render', async () => {
@ -3074,62 +2701,6 @@ 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 (
<Suspense fallback="Loading...">
<AsyncText text={text} />
</Suspense>
);
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App text="Initial" />);
});
assertLog(['Suspend! [Initial]']);
// Resolve initial render
await act(async () => {
await resolveText('Initial');
});
assertLog(['Initial']);
expect(root).toMatchRenderedOutput(<span prop="Initial" />);
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(<App text="First update" />);
});
} else {
root.render(<App text="First update" />);
}
await waitForAll(['Suspend! [First update]']);
// Should not display a fallback
expect(root).toMatchRenderedOutput(<span prop="Initial" />);
// Update again. This should also suspend for a JND.
if (gate(flags => flags.enableSyncDefaultUpdates)) {
React.startTransition(() => {
root.render(<App text="Second update" />);
});
} else {
root.render(<App text="Second update" />);
}
await waitForAll(['Suspend! [Second update]']);
// Should not display a fallback
expect(root).toMatchRenderedOutput(<span prop="Initial" />);
});
});
it('regression test: resets current "debug phase" after suspending', async () => {
function App() {
return (
@ -3391,14 +2962,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
setText('C');
});
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);
// First we attempt the high pri update. It suspends.
await waitForPaint(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
@ -3965,7 +3530,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="A" />
<span hidden={true} prop="A" />
<span prop="Loading..." />
<span prop="B" />
</>,
);
@ -4129,25 +3695,41 @@ describe('ReactSuspenseWithNoopRenderer', () => {
await act(async () => {
setText('B');
ReactNoop.idleUpdates(() => {
setText('B');
setText('C');
});
// Suspend the first update. The second update doesn't run because it has
// Idle priority.
await waitForAll(['Suspend! [B]', 'Loading...']);
// Commit the fallback. Now we'll try working on Idle.
jest.runAllTimers();
// Suspend the first update. This triggers an immediate fallback because
// it wasn't wrapped in startTransition.
await waitForPaint(['Suspend! [B]', 'Loading...']);
expect(root).toMatchRenderedOutput(
<>
<span hidden={true} prop="A" />
<span prop="Loading..." />
</>,
);
// It also suspends.
await waitForAll(['Suspend! [B]']);
// Once the fallback renders, proceed to the Idle update. This will
// also suspend.
await waitForAll(['Suspend! [C]']);
});
// 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(<span prop="B" />);
// Finish loading C.
await act(async () => {
setText('C');
await resolveText('C');
});
assertLog(['C']);
expect(root).toMatchRenderedOutput(<span prop="C" />);
});
// @gate enableLegacyCache