Skip to content

Commit 2586303

Browse files
authored
[Bugfix] Pending state is always user-blocking (#17382)
Fixes a bug where `isPending` is only set to `true` if `startTransition` is called from inside an input event. That's usually the case, but not always. Now it works regardless of where you call it.
1 parent 8e74a31 commit 2586303

File tree

2 files changed

+155
-54
lines changed

2 files changed

+155
-54
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

+53-54
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import type {HookEffectTag} from './ReactHookEffectTags';
1919
import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
2020
import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
2121

22-
import * as Scheduler from 'scheduler';
2322
import ReactSharedInternals from 'shared/ReactSharedInternals';
2423

2524
import {NoWork} from './ReactFiberExpirationTime';
@@ -53,7 +52,12 @@ import getComponentName from 'shared/getComponentName';
5352
import is from 'shared/objectIs';
5453
import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork';
5554
import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig';
56-
import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration';
55+
import {
56+
UserBlockingPriority,
57+
NormalPriority,
58+
runWithPriority,
59+
getCurrentPriorityLevel,
60+
} from './SchedulerWithReactIntegration';
5761

5862
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
5963

@@ -1135,15 +1139,13 @@ function mountDeferredValue<T>(
11351139
const [prevValue, setValue] = mountState(value);
11361140
mountEffect(
11371141
() => {
1138-
Scheduler.unstable_next(() => {
1139-
const previousConfig = ReactCurrentBatchConfig.suspense;
1140-
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1141-
try {
1142-
setValue(value);
1143-
} finally {
1144-
ReactCurrentBatchConfig.suspense = previousConfig;
1145-
}
1146-
});
1142+
const previousConfig = ReactCurrentBatchConfig.suspense;
1143+
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1144+
try {
1145+
setValue(value);
1146+
} finally {
1147+
ReactCurrentBatchConfig.suspense = previousConfig;
1148+
}
11471149
},
11481150
[value, config],
11491151
);
@@ -1157,65 +1159,62 @@ function updateDeferredValue<T>(
11571159
const [prevValue, setValue] = updateState(value);
11581160
updateEffect(
11591161
() => {
1160-
Scheduler.unstable_next(() => {
1161-
const previousConfig = ReactCurrentBatchConfig.suspense;
1162-
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1163-
try {
1164-
setValue(value);
1165-
} finally {
1166-
ReactCurrentBatchConfig.suspense = previousConfig;
1167-
}
1168-
});
1162+
const previousConfig = ReactCurrentBatchConfig.suspense;
1163+
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1164+
try {
1165+
setValue(value);
1166+
} finally {
1167+
ReactCurrentBatchConfig.suspense = previousConfig;
1168+
}
11691169
},
11701170
[value, config],
11711171
);
11721172
return prevValue;
11731173
}
11741174

1175+
function startTransition(setPending, config, callback) {
1176+
const priorityLevel = getCurrentPriorityLevel();
1177+
runWithPriority(
1178+
priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel,
1179+
() => {
1180+
setPending(true);
1181+
},
1182+
);
1183+
runWithPriority(
1184+
priorityLevel > NormalPriority ? NormalPriority : priorityLevel,
1185+
() => {
1186+
const previousConfig = ReactCurrentBatchConfig.suspense;
1187+
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1188+
try {
1189+
setPending(false);
1190+
callback();
1191+
} finally {
1192+
ReactCurrentBatchConfig.suspense = previousConfig;
1193+
}
1194+
},
1195+
);
1196+
}
1197+
11751198
function mountTransition(
11761199
config: SuspenseConfig | void | null,
11771200
): [(() => void) => void, boolean] {
11781201
const [isPending, setPending] = mountState(false);
1179-
const startTransition = mountCallback(
1180-
callback => {
1181-
setPending(true);
1182-
Scheduler.unstable_next(() => {
1183-
const previousConfig = ReactCurrentBatchConfig.suspense;
1184-
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1185-
try {
1186-
setPending(false);
1187-
callback();
1188-
} finally {
1189-
ReactCurrentBatchConfig.suspense = previousConfig;
1190-
}
1191-
});
1192-
},
1193-
[config, isPending],
1194-
);
1195-
return [startTransition, isPending];
1202+
const start = mountCallback(startTransition.bind(null, setPending, config), [
1203+
setPending,
1204+
config,
1205+
]);
1206+
return [start, isPending];
11961207
}
11971208

11981209
function updateTransition(
11991210
config: SuspenseConfig | void | null,
12001211
): [(() => void) => void, boolean] {
12011212
const [isPending, setPending] = updateState(false);
1202-
const startTransition = updateCallback(
1203-
callback => {
1204-
setPending(true);
1205-
Scheduler.unstable_next(() => {
1206-
const previousConfig = ReactCurrentBatchConfig.suspense;
1207-
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
1208-
try {
1209-
setPending(false);
1210-
callback();
1211-
} finally {
1212-
ReactCurrentBatchConfig.suspense = previousConfig;
1213-
}
1214-
});
1215-
},
1216-
[config, isPending],
1217-
);
1218-
return [startTransition, isPending];
1213+
const start = updateCallback(startTransition.bind(null, setPending, config), [
1214+
setPending,
1215+
config,
1216+
]);
1217+
return [start, isPending];
12191218
}
12201219

12211220
function dispatchAction<S, A>(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
* @jest-environment node
9+
*/
10+
11+
'use strict';
12+
13+
let ReactFeatureFlags;
14+
let React;
15+
let ReactNoop;
16+
let Scheduler;
17+
let Suspense;
18+
let useState;
19+
let useTransition;
20+
let act;
21+
22+
describe('ReactHooksWithNoopRenderer', () => {
23+
beforeEach(() => {
24+
jest.resetModules();
25+
26+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
27+
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
28+
ReactFeatureFlags.enableSchedulerTracing = true;
29+
ReactFeatureFlags.flushSuspenseFallbacksInTests = false;
30+
React = require('react');
31+
ReactNoop = require('react-noop-renderer');
32+
Scheduler = require('scheduler');
33+
useState = React.useState;
34+
useTransition = React.useTransition;
35+
Suspense = React.Suspense;
36+
act = ReactNoop.act;
37+
});
38+
39+
function Text(props) {
40+
Scheduler.unstable_yieldValue(props.text);
41+
return props.text;
42+
}
43+
44+
function createAsyncText(text) {
45+
let resolved = false;
46+
let Component = function() {
47+
if (!resolved) {
48+
Scheduler.unstable_yieldValue('Suspend! [' + text + ']');
49+
throw promise;
50+
}
51+
return <Text text={text} />;
52+
};
53+
let promise = new Promise(resolve => {
54+
Component.resolve = function() {
55+
resolved = true;
56+
return resolve();
57+
};
58+
});
59+
return Component;
60+
}
61+
62+
it('isPending works even if called from outside an input event', async () => {
63+
const Async = createAsyncText('Async');
64+
let start;
65+
function App() {
66+
const [show, setShow] = useState(false);
67+
const [startTransition, isPending] = useTransition();
68+
start = () => startTransition(() => setShow(true));
69+
return (
70+
<Suspense fallback={<Text text="Loading..." />}>
71+
{isPending ? <Text text="Pending..." /> : null}
72+
{show ? <Async /> : <Text text="(empty)" />}
73+
</Suspense>
74+
);
75+
}
76+
77+
const root = ReactNoop.createRoot();
78+
79+
await act(async () => {
80+
root.render(<App />);
81+
});
82+
expect(Scheduler).toHaveYielded(['(empty)']);
83+
expect(root).toMatchRenderedOutput('(empty)');
84+
85+
await act(async () => {
86+
start();
87+
88+
expect(Scheduler).toFlushAndYield([
89+
'Pending...',
90+
'(empty)',
91+
'Suspend! [Async]',
92+
'Loading...',
93+
]);
94+
95+
expect(root).toMatchRenderedOutput('Pending...(empty)');
96+
97+
await Async.resolve();
98+
});
99+
expect(Scheduler).toHaveYielded(['Async']);
100+
expect(root).toMatchRenderedOutput('Async');
101+
});
102+
});

0 commit comments

Comments
 (0)