Fix: useOptimistic should return passthrough value when there are no updates pending (#27936)
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.
Andrew Clark committed
Jan 13, 2024 at 21:37 UTC
60a927d04ad3888facebcdf7da620aa1cfc9528f
2 files changed
+57
-2
packages/react-reconciler/src/ReactFiberHooks.js
+11
-2
@@ -1256,10 +1256,19 @@ function updateReducerImpl<S, A>(
1256
queue.pending = null;
1257
}
1258
1259
- if (baseQueue !== null) {
1259
+ const baseState = hook.baseState;
1260
+ if (baseQueue === null) {
1261
+ // If there are no pending updates, then the memoized state should be the
1262
+ // same as the base state. Currently these only diverge in the case of
1263
+ // useOptimistic, because useOptimistic accepts a new baseState on
1264
+ // every render.
1265
+ hook.memoizedState = baseState;
1266
+ // We don't need to call markWorkInProgressReceivedUpdate because
1267
+ // baseState is derived from other reactive values.
1268
+ } else {
1269
// We have a queue to process.
1270
const first = baseQueue.next;
1262
- let newState = hook.baseState;
1271
+ let newState = baseState;
1272
1273
let newBaseState = null;
1274
let newBaseQueueFirst = null;
packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
+46
@@ -818,6 +818,52 @@ describe('ReactAsyncActions', () => {
818
);
819
});
820
821
+ // @gate enableAsyncActions
822
+ test(
823
+ 'regression: when there are no pending transitions, useOptimistic should ' +
824
+ 'always return the passthrough value',
825
+ async () => {
826
+ let setCanonicalState;
827
+ function App() {
828
+ const [canonicalState, _setCanonicalState] = useState(0);
829
+ const [optimisticState] = useOptimistic(canonicalState);
830
+ setCanonicalState = _setCanonicalState;
831
+
832
+ return (
833
+ <>
834
+ <div>
835
+ <Text text={'Canonical: ' + canonicalState} />
836
+ </div>
837
+ <div>
838
+ <Text text={'Optimistic: ' + optimisticState} />
839
+ </div>
840
+ </>
841
+ );
842
+ }
843
+
844
+ const root = ReactNoop.createRoot();
845
+ await act(() => root.render(<App />));
846
+ assertLog(['Canonical: 0', 'Optimistic: 0']);
847
+ expect(root).toMatchRenderedOutput(
848
+ <>
849
+ <div>Canonical: 0</div>
850
+ <div>Optimistic: 0</div>
851
+ </>,
852
+ );
853
+
854
+ // Update the canonical state. The optimistic state should update, too,
855
+ // even though there was no transition, and no call to setOptimisticState.
856
+ await act(() => setCanonicalState(1));
857
+ assertLog(['Canonical: 1', 'Optimistic: 1']);
858
+ expect(root).toMatchRenderedOutput(
859
+ <>
860
+ <div>Canonical: 1</div>
861
+ <div>Optimistic: 1</div>
862
+ </>,
863
+ );
864
+ },
865
+ );
866
+
867
// @gate enableAsyncActions
868
test('regression: useOptimistic during setState-in-render', async () => {
869
// This is a regression test for a very specific case where useOptimistic is