Skip to content

Commit 5a5fbe6

Browse files
committed
Warn if optimistic state is updated outside of a transition
If optimistic state is updated, and there's no startTransition on the stack, there are two likely scenarios. One possibility is that the optimistic update is triggered by a regular event handler (e.g. `onSubmit`) instead of an action. This is a mistake and we will warn. The other possibility is the optimistic update is inside an async action, but after an `await`. In this case, we can make it "just work" by associating the optimistic update with the pending async action. Technically it's possible that the optimistic update is unrelated to the pending action, but we don't have a way of knowing this for sure because browsers currently do not provide a way to track async scope. (The AsyncContext proposal, if it lands, will solve this in the future.) However, this is no different than the problem of unrelated transitions being grouped together — it's not wrong per se, but it's not ideal. Once AsyncContext starts landing in browsers, we will provide better warnings in development for these cases.
1 parent e6a8f50 commit 5a5fbe6

File tree

2 files changed

+99
-17
lines changed

2 files changed

+99
-17
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
143143
import {
144144
requestAsyncActionContext,
145145
requestSyncActionContext,
146+
peekEntangledActionLane,
146147
} from './ReactFiberAsyncAction';
147148
import {HostTransitionContext} from './ReactFiberHostContext';
148149
import {requestTransitionLane} from './ReactFiberRootScheduler';
@@ -2722,20 +2723,6 @@ function startTransition<S>(
27222723
);
27232724

27242725
const prevTransition = ReactCurrentBatchConfig.transition;
2725-
2726-
if (enableAsyncActions) {
2727-
// We don't really need to use an optimistic update here, because we
2728-
// schedule a second "revert" update below (which we use to suspend the
2729-
// transition until the async action scope has finished). But we'll use an
2730-
// optimistic update anyway to make it less likely the behavior accidentally
2731-
// diverges; for example, both an optimistic update and this one should
2732-
// share the same lane.
2733-
dispatchOptimisticSetState(fiber, false, queue, pendingState);
2734-
} else {
2735-
ReactCurrentBatchConfig.transition = null;
2736-
dispatchSetState(fiber, queue, pendingState);
2737-
}
2738-
27392726
const currentTransition = (ReactCurrentBatchConfig.transition =
27402727
({}: BatchConfigTransition));
27412728

@@ -2750,6 +2737,19 @@ function startTransition<S>(
27502737
ReactCurrentBatchConfig.transition._updatedFibers = new Set();
27512738
}
27522739

2740+
if (enableAsyncActions) {
2741+
// We don't really need to use an optimistic update here, because we
2742+
// schedule a second "revert" update below (which we use to suspend the
2743+
// transition until the async action scope has finished). But we'll use an
2744+
// optimistic update anyway to make it less likely the behavior accidentally
2745+
// diverges; for example, both an optimistic update and this one should
2746+
// share the same lane.
2747+
dispatchOptimisticSetState(fiber, false, queue, pendingState);
2748+
} else {
2749+
ReactCurrentBatchConfig.transition = null;
2750+
dispatchSetState(fiber, queue, pendingState);
2751+
}
2752+
27532753
try {
27542754
if (enableAsyncActions) {
27552755
const returnValue = callback();
@@ -3201,14 +3201,48 @@ function dispatchOptimisticSetState<S, A>(
32013201
queue: UpdateQueue<S, A>,
32023202
action: A,
32033203
): void {
3204+
if (__DEV__) {
3205+
if (ReactCurrentBatchConfig.transition === null) {
3206+
// An optimistic update occurred, but startTransition is not on the stack.
3207+
// There are two likely scenarios.
3208+
3209+
// One possibility is that the optimistic update is triggered by a regular
3210+
// event handler (e.g. `onSubmit`) instead of an action. This is a mistake
3211+
// and we will warn.
3212+
3213+
// The other possibility is the optimistic update is inside an async
3214+
// action, but after an `await`. In this case, we can make it "just work"
3215+
// by associating the optimistic update with the pending async action.
3216+
3217+
// Technically it's possible that the optimistic update is unrelated to
3218+
// the pending action, but we don't have a way of knowing this for sure
3219+
// because browsers currently do not provide a way to track async scope.
3220+
// (The AsyncContext proposal, if it lands, will solve this in the
3221+
// future.) However, this is no different than the problem of unrelated
3222+
// transitions being grouped together — it's not wrong per se, but it's
3223+
// not ideal.
3224+
3225+
// Once AsyncContext starts landing in browsers, we will provide better
3226+
// warnings in development for these cases.
3227+
if (peekEntangledActionLane() !== NoLane) {
3228+
// There is a pending async action. Don't warn.
3229+
} else {
3230+
// There's no pending async action. The most likely cause is that we're
3231+
// inside a regular event handler (e.g. onSubmit) instead of an action.
3232+
console.error(
3233+
'An optimistic state update occurred outside a transition or ' +
3234+
'action. To fix, move the update to an action, or wrap ' +
3235+
'with startTransition.',
3236+
);
3237+
}
3238+
}
3239+
}
3240+
32043241
const update: Update<S, A> = {
32053242
// An optimistic update commits synchronously.
32063243
lane: SyncLane,
32073244
// After committing, the optimistic update is "reverted" using the same
32083245
// lane as the transition it's associated with.
3209-
//
3210-
// TODO: Warn if there's no transition/action associated with this
3211-
// optimistic update.
32123246
revertLane: requestTransitionLane(),
32133247
action,
32143248
hasEagerState: false,

packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,4 +1175,52 @@ describe('ReactAsyncActions', () => {
11751175
await act(() => resolveText('Wait 2'));
11761176
assertLog(['B']);
11771177
});
1178+
1179+
// @gate enableAsyncActions
1180+
test('useOptimistic warns if outside of a transition', async () => {
1181+
let startTransition;
1182+
let setLoadingProgress;
1183+
let setText;
1184+
function App() {
1185+
const [, _startTransition] = useTransition();
1186+
const [text, _setText] = useState('A');
1187+
const [loadingProgress, _setLoadingProgress] = useOptimistic(0);
1188+
startTransition = _startTransition;
1189+
setText = _setText;
1190+
setLoadingProgress = _setLoadingProgress;
1191+
1192+
return (
1193+
<>
1194+
{loadingProgress !== 0 ? (
1195+
<div key="progress">
1196+
<Text text={`Loading... (${loadingProgress})`} />
1197+
</div>
1198+
) : null}
1199+
<div key="real">
1200+
<Text text={text} />
1201+
</div>
1202+
</>
1203+
);
1204+
}
1205+
1206+
// Initial render
1207+
const root = ReactNoop.createRoot();
1208+
await act(() => root.render(<App text="A" />));
1209+
assertLog(['A']);
1210+
expect(root).toMatchRenderedOutput(<div>A</div>);
1211+
1212+
await expect(async () => {
1213+
await act(() => {
1214+
setLoadingProgress('25%');
1215+
startTransition(() => setText('B'));
1216+
});
1217+
}).toErrorDev(
1218+
'An optimistic state update occurred outside a transition or ' +
1219+
'action. To fix, move the update to an action, or wrap ' +
1220+
'with startTransition.',
1221+
{withoutStack: true},
1222+
);
1223+
assertLog(['Loading... (25%)', 'A', 'B']);
1224+
expect(root).toMatchRenderedOutput(<div>B</div>);
1225+
});
11781226
});

0 commit comments

Comments
 (0)