diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 29c83c7d7263..dcc74d529f84 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1469,14 +1469,20 @@ function updateReducerImpl( // The transition that this optimistic update is associated with // has finished. Pretend the update doesn't exist by skipping // over it. + // + // Note: We intentionally don't check if this update is part of a + // pending async action here (by comparing revertLane to + // peekEntangledActionLane). The revert mechanism for useOptimistic + // should be isolated to the specific transition that triggered it, + // not blocked by the global entangled pending count. This allows + // Component B's optimistic UI to revert even when Component A has + // a slower, overlapping async action. + // + // Unlike non-optimistic updates, which use didReadFromEntangledAsyncAction + // to batch updates within the same async action scope, optimistic updates + // have their own revert mechanism via revertLane. The revertLane already + // encodes which transition the optimistic update is associated with. update = update.next; - - // Check if this update is part of a pending async action. If so, - // we'll need to suspend until the action has finished, so that it's - // batched together with future updates in the same action. - if (revertLane === peekEntangledActionLane()) { - didReadFromEntangledAsyncAction = true; - } continue; } else { const clone: Update = { diff --git a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js index 26874881dca5..7329cd260e7c 100644 --- a/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js +++ b/packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js @@ -1865,4 +1865,162 @@ describe('ReactAsyncActions', () => { , ); }); + + // Regression test for https://github.com/facebook/react/issues/36318 + // + // This test verifies that useOptimistic's revert mechanism is isolated to + // the specific action that triggered it, rather than being blocked by + // the global entangled pending count. + // + // Scenario: + // - Component A has a slow async action + // - Component B has a fast async action + // - Component B's optimistic UI should revert when its own action finishes, + // not when Component A's slow action finishes + it( + 'useOptimistic reverts when its own action finishes, not when other ' + + 'overlapping async actions finish', + async () => { + const startTransition = React.startTransition; + + // Component A: Has a slow async action + let setTextA; + let setOptimisticTextA; + function ComponentA() { + const [canonicalText, _setText] = useState('A-Initial'); + setTextA = _setText; + + const [text, _setOptimisticText] = useOptimistic( + canonicalText, + (_, optimisticText) => `${optimisticText} (loading...)`, + ); + setOptimisticTextA = _setOptimisticText; + + return ( + + + + ); + } + + // Component B: Has a fast async action + let setTextB; + let setOptimisticTextB; + function ComponentB() { + const [canonicalText, _setText] = useState('B-Initial'); + setTextB = _setText; + + const [text, _setOptimisticText] = useOptimistic( + canonicalText, + (_, optimisticText) => `${optimisticText} (loading...)`, + ); + setOptimisticTextB = _setOptimisticText; + + return ( + + + + ); + } + + function App() { + return ( + <> + + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog(['A-Initial', 'B-Initial']); + expect(root).toMatchRenderedOutput( + <> + A-Initial + B-Initial + , + ); + + // Start Component A's slow async action first + await act(() => { + startTransition(async () => { + Scheduler.log('Component A async action started'); + setOptimisticTextA('A-Updated'); + await getText('Component A: Slow operation'); + Scheduler.log('Component A async action ended'); + startTransition(() => setTextA('A-Updated')); + }); + }); + // Component A's optimistic UI is shown + assertLog([ + 'Component A async action started', + 'A-Updated (loading...)', + 'B-Initial', + ]); + expect(root).toMatchRenderedOutput( + <> + A-Updated (loading...) + B-Initial + , + ); + + // Start Component B's fast async action while Component A's action is still pending + await act(() => { + startTransition(async () => { + Scheduler.log('Component B async action started'); + setOptimisticTextB('B-Updated'); + await getText('Component B: Fast operation'); + Scheduler.log('Component B async action ended'); + startTransition(() => setTextB('B-Updated')); + }); + }); + // Component B's optimistic UI is shown + assertLog([ + 'Component B async action started', + 'A-Updated (loading...)', + 'B-Updated (loading...)', + ]); + expect(root).toMatchRenderedOutput( + <> + A-Updated (loading...) + B-Updated (loading...) + , + ); + + // Finish Component B's fast action. Component B's optimistic UI should + // revert now, even though Component A's slow action is still pending. + // + // This is the key assertion for the fix: Component B's optimistic state + // should not be blocked by Component A's pending action. + await act(() => resolveText('Component B: Fast operation')); + assertLog([ + 'Component B async action ended', + 'A-Updated (loading...)', + 'B-Updated', + ]); + expect(root).toMatchRenderedOutput( + <> + A-Updated (loading...) + B-Updated + , + ); + + // Now finish Component A's slow action + await act(() => resolveText('Component A: Slow operation')); + assertLog([ + 'Component A async action ended', + 'A-Updated', + 'B-Updated', + ]); + expect(root).toMatchRenderedOutput( + <> + A-Updated + B-Updated + , + ); + }, + ); });