Skip to content

Commit c25c59c

Browse files
authored
Apply the Just Noticeable Difference to suspense timeouts (#15367)
* Apply the Just Noticeable Difference boundary * Clamp suspense timeout to expiration time
1 parent 3e2e930 commit c25c59c

File tree

4 files changed

+239
-13
lines changed

4 files changed

+239
-13
lines changed

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,9 @@ function completeWork(
692692
// Mark the event time of the switching from fallback to normal children,
693693
// based on the start of when we first showed the fallback. This time
694694
// was given a normal pri expiration time at the time it was shown.
695-
const fallbackExpirationTimeExpTime: ExpirationTime =
695+
const fallbackExpirationTime: ExpirationTime =
696696
prevState.fallbackExpirationTime;
697-
markRenderEventTime(fallbackExpirationTimeExpTime);
697+
markRenderEventTime(fallbackExpirationTime);
698698

699699
// Delete the fallback.
700700
// TODO: Would it be better to store the fallback fragment on

packages/react-reconciler/src/ReactFiberScheduler.new.js

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ import {
160160
} from 'shared/ReactErrorUtils';
161161
import {onCommitRoot} from './ReactFiberDevToolsHook';
162162

163+
const ceil = Math.ceil;
164+
163165
const {
164166
ReactCurrentDispatcher,
165167
ReactCurrentOwner,
@@ -893,10 +895,12 @@ function renderRoot(
893895
// track any event times. That can happen if we retried but nothing switched
894896
// from fallback to content. There's no reason to delay doing no work.
895897
if (workInProgressRootMostRecentEventTime !== Sync) {
896-
const msUntilTimeout = computeMsUntilTimeout(
898+
let msUntilTimeout = computeMsUntilTimeout(
897899
workInProgressRootMostRecentEventTime,
900+
expirationTime,
898901
);
899-
if (msUntilTimeout > 0) {
902+
// Don't bother with a very short suspense time.
903+
if (msUntilTimeout > 10) {
900904
// The render is suspended, it hasn't timed out, and there's no lower
901905
// priority work to do. Instead of committing the fallback
902906
// immediately, wait for more data to arrive.
@@ -1815,7 +1819,35 @@ export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) {
18151819
retryTimedOutBoundary(boundaryFiber);
18161820
}
18171821

1818-
function computeMsUntilTimeout(mostRecentEventTime: ExpirationTime) {
1822+
// Computes the next Just Noticeable Difference (JND) boundary.
1823+
// The theory is that a person can't tell the difference between small differences in time.
1824+
// Therefore, if we wait a bit longer than necessary that won't translate to a noticeable
1825+
// difference in the experience. However, waiting for longer might mean that we can avoid
1826+
// showing an intermediate loading state. The longer we have already waited, the harder it
1827+
// is to tell small differences in time. Therefore, the longer we've already waited,
1828+
// the longer we can wait additionally. At some point we have to give up though.
1829+
// We pick a train model where the next boundary commits at a consistent schedule.
1830+
// These particular numbers are vague estimates. We expect to adjust them based on research.
1831+
function jnd(timeElapsed: number) {
1832+
return timeElapsed < 120
1833+
? 120
1834+
: timeElapsed < 480
1835+
? 480
1836+
: timeElapsed < 1080
1837+
? 1080
1838+
: timeElapsed < 1920
1839+
? 1920
1840+
: timeElapsed < 3000
1841+
? 3000
1842+
: timeElapsed < 4320
1843+
? 4320
1844+
: ceil(timeElapsed / 1960) * 1960;
1845+
}
1846+
1847+
function computeMsUntilTimeout(
1848+
mostRecentEventTime: ExpirationTime,
1849+
committedExpirationTime: ExpirationTime,
1850+
) {
18191851
if (disableYielding) {
18201852
// Timeout immediately when yielding is disabled.
18211853
return 0;
@@ -1825,11 +1857,21 @@ function computeMsUntilTimeout(mostRecentEventTime: ExpirationTime) {
18251857
const currentTimeMs: number = now();
18261858
const timeElapsed = currentTimeMs - eventTimeMs;
18271859

1828-
// TODO: Account for the Just Noticeable Difference
1829-
const timeoutMs = 150;
1830-
const msUntilTimeout = timeoutMs - timeElapsed;
1860+
let msUntilTimeout = jnd(timeElapsed) - timeElapsed;
1861+
1862+
// Compute the time until this render pass would expire.
1863+
const timeUntilExpirationMs =
1864+
expirationTimeToMs(committedExpirationTime) + initialTimeMs - currentTimeMs;
1865+
1866+
// Clamp the timeout to the expiration time.
1867+
// TODO: Once the event time is exact instead of inferred from expiration time
1868+
// we don't need this.
1869+
if (timeUntilExpirationMs < msUntilTimeout) {
1870+
msUntilTimeout = timeUntilExpirationMs;
1871+
}
1872+
18311873
// This is the value that is passed to `setTimeout`.
1832-
return msUntilTimeout < 0 ? 0 : msUntilTimeout;
1874+
return msUntilTimeout;
18331875
}
18341876

18351877
function checkForNestedUpdates() {

packages/react-reconciler/src/ReactFiberScheduler.old.js

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,22 @@ function workLoop(isYieldy) {
12411241
}
12421242
}
12431243

1244+
function jnd(timeElapsed: number) {
1245+
return timeElapsed < 120
1246+
? 120
1247+
: timeElapsed < 480
1248+
? 480
1249+
: timeElapsed < 1080
1250+
? 1080
1251+
: timeElapsed < 1920
1252+
? 1920
1253+
: timeElapsed < 3000
1254+
? 3000
1255+
: timeElapsed < 4320
1256+
? 4320
1257+
: Math.ceil(timeElapsed / 1960) * 1960;
1258+
}
1259+
12441260
function renderRoot(root: FiberRoot, isYieldy: boolean): void {
12451261
invariant(
12461262
!isWorking,
@@ -1518,10 +1534,22 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void {
15181534
const currentTimeMs: number = now();
15191535
const timeElapsed = currentTimeMs - eventTimeMs;
15201536

1521-
// TODO: Account for the Just Noticeable Difference
1522-
const timeoutMs = 150;
1523-
let msUntilTimeout = timeoutMs - timeElapsed;
1524-
msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout;
1537+
let msUntilTimeout = jnd(timeElapsed) - timeElapsed;
1538+
1539+
if (msUntilTimeout < 10) {
1540+
// Don't bother with a very short suspense time.
1541+
msUntilTimeout = 0;
1542+
} else {
1543+
// Compute the time until this render pass would expire.
1544+
const timeUntilExpirationMs =
1545+
expirationTimeToMs(suspendedExpirationTime) +
1546+
originalStartTimeMs -
1547+
currentTimeMs;
1548+
// Clamp the timeout to the expiration time.
1549+
if (timeUntilExpirationMs < msUntilTimeout) {
1550+
msUntilTimeout = timeUntilExpirationMs;
1551+
}
1552+
}
15251553

15261554
const rootExpirationTime = root.expirationTime;
15271555
onSuspend(

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,6 +1668,162 @@ describe('ReactSuspenseWithNoopRenderer', () => {
16681668
</React.Fragment>,
16691669
);
16701670
});
1671+
1672+
it('suspends for longer if something took a long (CPU bound) time to render', async () => {
1673+
function Foo() {
1674+
Scheduler.yieldValue('Foo');
1675+
return (
1676+
<Suspense fallback={<Text text="Loading..." />}>
1677+
<AsyncText text="A" ms={5000} />
1678+
</Suspense>
1679+
);
1680+
}
1681+
1682+
ReactNoop.render(<Foo />);
1683+
Scheduler.advanceTime(100);
1684+
await advanceTimers(100);
1685+
// Start rendering
1686+
expect(Scheduler).toFlushAndYieldThrough(['Foo']);
1687+
// For some reason it took a long time to render Foo.
1688+
Scheduler.advanceTime(1250);
1689+
await advanceTimers(1250);
1690+
expect(Scheduler).toFlushAndYield([
1691+
// A suspends
1692+
'Suspend! [A]',
1693+
'Loading...',
1694+
]);
1695+
// We're now suspended and we haven't shown anything yet.
1696+
expect(ReactNoop.getChildren()).toEqual([]);
1697+
1698+
// Flush some of the time
1699+
Scheduler.advanceTime(450);
1700+
await advanceTimers(450);
1701+
// Because we've already been waiting for so long we can
1702+
// wait a bit longer. Still nothing...
1703+
expect(Scheduler).toFlushWithoutYielding();
1704+
expect(ReactNoop.getChildren()).toEqual([]);
1705+
1706+
// Eventually we'll show the fallback.
1707+
Scheduler.advanceTime(500);
1708+
await advanceTimers(500);
1709+
// No need to rerender.
1710+
expect(Scheduler).toFlushWithoutYielding();
1711+
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1712+
1713+
// Flush the promise completely
1714+
Scheduler.advanceTime(4500);
1715+
await advanceTimers(4500);
1716+
// Renders successfully
1717+
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1718+
expect(Scheduler).toFlushAndYield(['A']);
1719+
expect(ReactNoop.getChildren()).toEqual([span('A')]);
1720+
});
1721+
1722+
it('suspends for longer if a fallback has been shown for a long time', async () => {
1723+
function Foo() {
1724+
Scheduler.yieldValue('Foo');
1725+
return (
1726+
<Suspense fallback={<Text text="Loading..." />}>
1727+
<AsyncText text="A" ms={5000} />
1728+
<Suspense fallback={<Text text="Loading more..." />}>
1729+
<AsyncText text="B" ms={10000} />
1730+
</Suspense>
1731+
</Suspense>
1732+
);
1733+
}
1734+
1735+
ReactNoop.render(<Foo />);
1736+
// Start rendering
1737+
expect(Scheduler).toFlushAndYield([
1738+
'Foo',
1739+
// A suspends
1740+
'Suspend! [A]',
1741+
// B suspends
1742+
'Suspend! [B]',
1743+
'Loading more...',
1744+
'Loading...',
1745+
]);
1746+
// We're now suspended and we haven't shown anything yet.
1747+
expect(ReactNoop.getChildren()).toEqual([]);
1748+
1749+
// Show the fallback.
1750+
Scheduler.advanceTime(400);
1751+
await advanceTimers(400);
1752+
expect(Scheduler).toFlushWithoutYielding();
1753+
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1754+
1755+
// Wait a long time.
1756+
Scheduler.advanceTime(5000);
1757+
await advanceTimers(5000);
1758+
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1759+
1760+
// Retry with the new content.
1761+
expect(Scheduler).toFlushAndYield([
1762+
'A',
1763+
// B still suspends
1764+
'Suspend! [B]',
1765+
'Loading more...',
1766+
]);
1767+
// Because we've already been waiting for so long we can
1768+
// wait a bit longer. Still nothing...
1769+
Scheduler.advanceTime(600);
1770+
await advanceTimers(600);
1771+
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1772+
1773+
// Eventually we'll show more content with inner fallback.
1774+
Scheduler.advanceTime(3000);
1775+
await advanceTimers(3000);
1776+
// No need to rerender.
1777+
expect(Scheduler).toFlushWithoutYielding();
1778+
expect(ReactNoop.getChildren()).toEqual([
1779+
span('A'),
1780+
span('Loading more...'),
1781+
]);
1782+
1783+
// Flush the last promise completely
1784+
Scheduler.advanceTime(4500);
1785+
await advanceTimers(4500);
1786+
// Renders successfully
1787+
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1788+
expect(Scheduler).toFlushAndYield(['B']);
1789+
expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
1790+
});
1791+
1792+
it('does not suspend for very long after a higher priority update', async () => {
1793+
function Foo() {
1794+
Scheduler.yieldValue('Foo');
1795+
return (
1796+
<Suspense fallback={<Text text="Loading..." />}>
1797+
<AsyncText text="A" ms={5000} />
1798+
</Suspense>
1799+
);
1800+
}
1801+
1802+
ReactNoop.interactiveUpdates(() => ReactNoop.render(<Foo />));
1803+
expect(Scheduler).toFlushAndYieldThrough(['Foo']);
1804+
1805+
// Advance some time.
1806+
Scheduler.advanceTime(100);
1807+
await advanceTimers(100);
1808+
1809+
expect(Scheduler).toFlushAndYield([
1810+
// A suspends
1811+
'Suspend! [A]',
1812+
'Loading...',
1813+
]);
1814+
// We're now suspended and we haven't shown anything yet.
1815+
expect(ReactNoop.getChildren()).toEqual([]);
1816+
1817+
// Flush some of the time
1818+
Scheduler.advanceTime(500);
1819+
await advanceTimers(500);
1820+
// We should have already shown the fallback.
1821+
// When we wrote this test, we inferred the start time of high priority
1822+
// updates as way earlier in the past. This test ensures that we don't
1823+
// use this assumption to add a very long JND.
1824+
expect(Scheduler).toFlushWithoutYielding();
1825+
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1826+
});
16711827
});
16721828

16731829
// TODO:

0 commit comments

Comments
 (0)