Skip to content

Commit f9e60c8

Browse files
authored
Warn when suspending at wrong priority (#15492)
* Warn when suspending at wrong priority Adds a warning when a user-blocking update is suspended. Ideally, all we would need to do is check the current priority level. But we currently have no rigorous way to distinguish work that was scheduled at user- blocking priority from work that expired a bit and was "upgraded" to a higher priority. That's because we don't schedule separate callbacks for every level, only the highest priority level per root. The priority of subsequent levels is inferred from the expiration time, but this is an imprecise heuristic. However, we do store the last discrete pending update per root. So we can reliably compare to that one. (If we broaden this warning to include high pri updates that aren't discrete, then this won't be sufficient.) My rationale is that it's better for this warning to have false negatives than false positives. Potential follow-ups: - Bikeshed more on the message. I don't like what I landed on that much but I think it's good enough to start. - Include the names of the components that updated. (The ideal place to fire the warning is during the setState call but we don't know if something will suspend until the next update. Maybe we could be clever and warn during a subsequent update to the same component?) * Move Suspense priority check to throwException
1 parent 89d8d14 commit f9e60c8

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

packages/react-reconciler/src/ReactFiberScheduler.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ function prepareFreshStack(root, expirationTime) {
698698

699699
if (__DEV__) {
700700
ReactStrictModeWarnings.discardPendingWarnings();
701+
componentsWithSuspendedDiscreteUpdates = null;
701702
}
702703
}
703704

@@ -1225,6 +1226,7 @@ function commitRoot(root, expirationTime) {
12251226
function commitRootImpl(root, expirationTime) {
12261227
flushPassiveEffects();
12271228
flushRenderPhaseStrictModeWarningsInDEV();
1229+
flushSuspensePriorityWarningInDEV();
12281230

12291231
invariant(
12301232
workPhase !== RenderPhase && workPhase !== CommitPhase,
@@ -2114,6 +2116,76 @@ function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void {
21142116

21152117
export const warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDEV;
21162118

2119+
let componentsWithSuspendedDiscreteUpdates = null;
2120+
export function checkForWrongSuspensePriorityInDEV(sourceFiber: Fiber) {
2121+
if (__DEV__) {
2122+
if (
2123+
(sourceFiber.mode & ConcurrentMode) !== NoEffect &&
2124+
// Check if we're currently rendering a discrete update. Ideally, all we
2125+
// would need to do is check the current priority level. But we currently
2126+
// have no rigorous way to distinguish work that was scheduled at user-
2127+
// blocking priority from work that expired a bit and was "upgraded" to
2128+
// a higher priority. That's because we don't schedule separate callbacks
2129+
// for every level, only the highest priority level per root. The priority
2130+
// of subsequent levels is inferred from the expiration time, but this is
2131+
// an imprecise heuristic.
2132+
//
2133+
// However, we do store the last discrete pending update per root. So we
2134+
// can reliably compare to that one. (If we broaden this warning to include
2135+
// high pri updates that aren't discrete, then this won't be sufficient.)
2136+
//
2137+
// My rationale is that it's better for this warning to have false
2138+
// negatives than false positives.
2139+
rootsWithPendingDiscreteUpdates !== null &&
2140+
workInProgressRoot !== null &&
2141+
renderExpirationTime ===
2142+
rootsWithPendingDiscreteUpdates.get(workInProgressRoot)
2143+
) {
2144+
// Add the component name to a set.
2145+
const componentName = getComponentName(sourceFiber.type);
2146+
if (componentsWithSuspendedDiscreteUpdates === null) {
2147+
componentsWithSuspendedDiscreteUpdates = new Set([componentName]);
2148+
} else {
2149+
componentsWithSuspendedDiscreteUpdates.add(componentName);
2150+
}
2151+
}
2152+
}
2153+
}
2154+
2155+
function flushSuspensePriorityWarningInDEV() {
2156+
if (__DEV__) {
2157+
if (componentsWithSuspendedDiscreteUpdates !== null) {
2158+
const componentNames = [];
2159+
componentsWithSuspendedDiscreteUpdates.forEach(name => {
2160+
componentNames.push(name);
2161+
});
2162+
componentsWithSuspendedDiscreteUpdates = null;
2163+
2164+
// TODO: A more helpful version of this message could include the names of
2165+
// the component that were updated, not the ones that suspended. To do
2166+
// that we'd need to track all the components that updated during this
2167+
// render, perhaps using the same mechanism as `markRenderEventTime`.
2168+
warningWithoutStack(
2169+
false,
2170+
'The following components suspended during a user-blocking update: %s' +
2171+
'\n\n' +
2172+
'Updates triggered by user interactions (e.g. click events) are ' +
2173+
'considered user-blocking by default. They should not suspend. ' +
2174+
'Updates that can afford to take a bit longer should be wrapped ' +
2175+
'with `Scheduler.next` (or an equivalent abstraction). This ' +
2176+
'typically includes any update that shows new content, like ' +
2177+
'a navigation.' +
2178+
'\n\n' +
2179+
'Generally, you should split user interactions into at least two ' +
2180+
'seprate updates: a user-blocking update to provide immediate ' +
2181+
'feedback, and another update to perform the actual change.',
2182+
// TODO: Add link to React docs with more information, once it exists
2183+
componentNames.sort().join(', '),
2184+
);
2185+
}
2186+
}
2187+
}
2188+
21172189
function computeThreadID(root, expirationTime) {
21182190
// Interaction threads are unique per root and expiration time.
21192191
return expirationTime * 1000 + root.interactionThreadID;

packages/react-reconciler/src/ReactFiberUnwindWork.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
isAlreadyFailedLegacyErrorBoundary,
6969
pingSuspendedRoot,
7070
resolveRetryThenable,
71+
checkForWrongSuspensePriorityInDEV,
7172
} from './ReactFiberScheduler';
7273

7374
import invariant from 'shared/invariant';
@@ -203,6 +204,8 @@ function throwException(
203204
// This is a thenable.
204205
const thenable: Thenable = (value: any);
205206

207+
checkForWrongSuspensePriorityInDEV(sourceFiber);
208+
206209
// Schedule the nearest Suspense to re-render the timed out view.
207210
let workInProgress = returnFiber;
208211
do {

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1715,12 +1715,61 @@ describe('ReactSuspenseWithNoopRenderer', () => {
17151715

17161716
// Flush some of the time
17171717
Scheduler.advanceTime(500);
1718-
await advanceTimers(500);
1718+
expect(() => {
1719+
jest.advanceTimersByTime(500);
1720+
}).toWarnDev(
1721+
'The following components suspended during a user-blocking ' +
1722+
'update: AsyncText',
1723+
{withoutStack: true},
1724+
);
1725+
17191726
// We should have already shown the fallback.
17201727
// When we wrote this test, we inferred the start time of high priority
17211728
// updates as way earlier in the past. This test ensures that we don't
17221729
// use this assumption to add a very long JND.
17231730
expect(Scheduler).toFlushWithoutYielding();
17241731
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
17251732
});
1733+
1734+
it('warns when suspending inside discrete update', async () => {
1735+
function A() {
1736+
TextResource.read(['A', 1000]);
1737+
return 'A';
1738+
}
1739+
1740+
function B() {
1741+
return 'B';
1742+
}
1743+
1744+
function C() {
1745+
TextResource.read(['C', 1000]);
1746+
return 'C';
1747+
}
1748+
1749+
function App() {
1750+
return (
1751+
<Suspense fallback="Loading...">
1752+
<A />
1753+
<B />
1754+
<C />
1755+
<C />
1756+
<C />
1757+
<C />
1758+
</Suspense>
1759+
);
1760+
}
1761+
1762+
ReactNoop.interactiveUpdates(() => ReactNoop.render(<App />));
1763+
Scheduler.flushAll();
1764+
1765+
// Warning is not flushed until the commit phase
1766+
1767+
// Timeout and commit the fallback
1768+
expect(() => {
1769+
jest.advanceTimersByTime(1000);
1770+
}).toWarnDev(
1771+
'The following components suspended during a user-blocking update: A, C',
1772+
{withoutStack: true},
1773+
);
1774+
});
17261775
});

0 commit comments

Comments
 (0)