diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 530ba04d6d27a..2b27777c6c399 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1793,7 +1793,20 @@ function updateOptimistic( reducer: ?(S, A) => S, ): [S, (A) => void] { const hook = updateWorkInProgressHook(); + return updateOptimisticImpl( + hook, + ((currentHook: any): Hook), + passthrough, + reducer, + ); +} +function updateOptimisticImpl( + hook: Hook, + current: Hook | null, + passthrough: S, + reducer: ?(S, A) => S, +): [S, (A) => void] { // Optimistic updates are always rebased on top of the latest value passed in // as an argument. It's called a passthrough because if there are no pending // updates, it will be returned as-is. @@ -1820,14 +1833,20 @@ function rerenderOptimistic( // So instead of a forked re-render implementation that knows how to handle // render phase udpates, we can use the same implementation as during a // regular mount or update. + const hook = updateWorkInProgressHook(); if (currentHook !== null) { // This is an update. Process the update queue. - return updateOptimistic(passthrough, reducer); + return updateOptimisticImpl( + hook, + ((currentHook: any): Hook), + passthrough, + reducer, + ); } // This is a mount. No updates to process. - const hook = updateWorkInProgressHook(); + // Reset the base state and memoized state to the passthrough. Future // updates will be applied on top of this. hook.baseState = hook.memoizedState = passthrough; diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js index 65b14b87f569c..a333c4c486b4a 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -818,6 +818,44 @@ describe('ReactAsyncActions', () => { ); }); + // @gate enableAsyncActions + test('regression: useOptimistic during setState-in-render', async () => { + // This is a regression test for a very specific case where useOptimistic is + // the first hook in the component, it has a pending update, and a later + // hook schedules a local (setState-in-render) update. Don't sweat about + // deleting this test if the implementation details change. + + let setOptimisticState; + let startTransition; + function App() { + const [optimisticState, _setOptimisticState] = useOptimistic(0); + setOptimisticState = _setOptimisticState; + const [, _startTransition] = useTransition(); + startTransition = _startTransition; + + const [derivedState, setDerivedState] = useState(0); + if (derivedState !== optimisticState) { + setDerivedState(optimisticState); + } + + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog([0]); + expect(root).toMatchRenderedOutput('0'); + + await act(() => { + startTransition(async () => { + setOptimisticState(1); + await getText('Wait'); + }); + }); + assertLog([1]); + expect(root).toMatchRenderedOutput('1'); + }); + // @gate enableAsyncActions test('useOptimistic accepts a custom reducer', async () => { let serverCart = ['A'];