Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Warn if optimistic state is updated outside of a transition #27454

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
import {
requestAsyncActionContext,
requestSyncActionContext,
peekEntangledActionLane,
} from './ReactFiberAsyncAction';
import {HostTransitionContext} from './ReactFiberHostContext';
import {requestTransitionLane} from './ReactFiberRootScheduler';
Expand Down Expand Up @@ -2722,6 +2723,7 @@ function startTransition<S>(
);

const prevTransition = ReactCurrentBatchConfig.transition;
const currentTransition: BatchConfigTransition = {};

if (enableAsyncActions) {
// We don't really need to use an optimistic update here, because we
Expand All @@ -2730,15 +2732,14 @@ function startTransition<S>(
// optimistic update anyway to make it less likely the behavior accidentally
// diverges; for example, both an optimistic update and this one should
// share the same lane.
ReactCurrentBatchConfig.transition = currentTransition;
dispatchOptimisticSetState(fiber, false, queue, pendingState);
} else {
ReactCurrentBatchConfig.transition = null;
dispatchSetState(fiber, queue, pendingState);
ReactCurrentBatchConfig.transition = currentTransition;
}

const currentTransition = (ReactCurrentBatchConfig.transition =
({}: BatchConfigTransition));

if (enableTransitionTracing) {
if (options !== undefined && options.name !== undefined) {
ReactCurrentBatchConfig.transition.name = options.name;
Expand Down Expand Up @@ -3201,14 +3202,48 @@ function dispatchOptimisticSetState<S, A>(
queue: UpdateQueue<S, A>,
action: A,
): void {
if (__DEV__) {
if (ReactCurrentBatchConfig.transition === null) {
// An optimistic update occurred, but startTransition is not 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.
if (peekEntangledActionLane() !== NoLane) {
// There is a pending async action. Don't warn.
} else {
// There's no pending async action. The most likely cause is that we're
// inside a regular event handler (e.g. onSubmit) instead of an action.
console.error(
'An optimistic state update occurred outside a transition or ' +
'action. To fix, move the update to an action, or wrap ' +
'with startTransition.',
);
}
}
}

const update: Update<S, A> = {
// An optimistic update commits synchronously.
lane: SyncLane,
// After committing, the optimistic update is "reverted" using the same
// lane as the transition it's associated with.
//
// TODO: Warn if there's no transition/action associated with this
// optimistic update.
revertLane: requestTransitionLane(),
action,
hasEagerState: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1142,7 +1142,7 @@ describe('ReactAsyncActions', () => {

// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render(<App text="A" />));
await act(() => root.render(<App />));
assertLog(['A']);
expect(root).toMatchRenderedOutput(<div>A</div>);

Expand Down Expand Up @@ -1174,5 +1174,54 @@ describe('ReactAsyncActions', () => {

await act(() => resolveText('Wait 2'));
assertLog(['B']);
expect(root).toMatchRenderedOutput(<div>B</div>);
});

// @gate enableAsyncActions
test('useOptimistic warns if outside of a transition', async () => {
let startTransition;
let setLoadingProgress;
let setText;
function App() {
const [, _startTransition] = useTransition();
const [text, _setText] = useState('A');
const [loadingProgress, _setLoadingProgress] = useOptimistic(0);
startTransition = _startTransition;
setText = _setText;
setLoadingProgress = _setLoadingProgress;

return (
<>
{loadingProgress !== 0 ? (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make this unconditional so it’s easier to witness that the state gets set back? Right now the test logs sorta make it look like it gets stuck at 25.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait never mind I'm silly. The toMatchRenderedOutput is plenty.

<div key="progress">
<Text text={`Loading... (${loadingProgress})`} />
</div>
) : null}
<div key="real">
<Text text={text} />
</div>
</>
);
}

// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog(['A']);
expect(root).toMatchRenderedOutput(<div>A</div>);

await expect(async () => {
await act(() => {
setLoadingProgress('25%');
startTransition(() => setText('B'));
});
}).toErrorDev(
'An optimistic state update occurred outside a transition or ' +
'action. To fix, move the update to an action, or wrap ' +
'with startTransition.',
{withoutStack: true},
);
assertLog(['Loading... (25%)', 'A', 'B']);
expect(root).toMatchRenderedOutput(<div>B</div>);
});
});