From 4e99264c62db7ca2bb6c999d124b39b9c964e6a7 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Sat, 13 Jan 2024 19:47:36 -0500 Subject: [PATCH] Fix: useOptimistic returns passthrough when nothing pending This fixes a bug that happened when the canonical value passed to useOptimistic without an accompanying call to setOptimistic. In this scenario, useOptimistic should pass through the new canonical value. I had written tests for the more complicated scenario, where a new value is passed while there are still pending optimistic updates, but not this simpler one. --- .../react-reconciler/src/ReactFiberHooks.js | 13 +++++- .../src/__tests__/ReactAsyncActions-test.js | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index e58c5484ef920..12c3987095284 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1256,10 +1256,19 @@ function updateReducerImpl( queue.pending = null; } - if (baseQueue !== null) { + const baseState = hook.baseState; + if (baseQueue === null) { + // If there are no pending updates, then the memoized state should be the + // same as the base state. Currently these only diverge in the case of + // useOptimistic, because useOptimistic accepts a new baseState on + // every render. + hook.memoizedState = baseState; + // We don't need to call markWorkInProgressReceivedUpdate because + // baseState is derived from other reactive values. + } else { // We have a queue to process. const first = baseQueue.next; - let newState = hook.baseState; + let newState = baseState; let newBaseState = null; let newBaseQueueFirst = null; diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js index 0bf859f188627..1be91d36084e6 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -818,6 +818,52 @@ describe('ReactAsyncActions', () => { ); }); + // @gate enableAsyncActions + test( + 'regression: when there are no pending transitions, useOptimistic should ' + + 'always return the passthrough value', + async () => { + let setCanonicalState; + function App() { + const [canonicalState, _setCanonicalState] = useState(0); + const [optimisticState] = useOptimistic(canonicalState); + setCanonicalState = _setCanonicalState; + + return ( + <> +
+ +
+
+ +
+ + ); + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog(['Canonical: 0', 'Optimistic: 0']); + expect(root).toMatchRenderedOutput( + <> +
Canonical: 0
+
Optimistic: 0
+ , + ); + + // Update the canonical state. The optimistic state should update, too, + // even though there was no transition, and no call to setOptimisticState. + await act(() => setCanonicalState(1)); + assertLog(['Canonical: 1', 'Optimistic: 1']); + expect(root).toMatchRenderedOutput( + <> +
Canonical: 1
+
Optimistic: 1
+ , + ); + }, + ); + // @gate enableAsyncActions test('regression: useOptimistic during setState-in-render', async () => { // This is a regression test for a very specific case where useOptimistic is