From 44b130e4a82b14b30c93cf7c54b25ce6c8cfcab2 Mon Sep 17 00:00:00 2001 From: CGW406 <13565294+cgw406@user.noreply.gitee.com> Date: Wed, 22 Apr 2026 12:50:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ReactFiberHooks):=20=E4=BF=AE=E5=A4=8Du?= =?UTF-8?q?seOptimistic=E5=9C=A8=E5=B9=B6=E5=8F=91=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E8=BF=98=E5=8E=9F=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除对全局异步操作状态的检查,确保乐观更新的还原仅与触发它的特定操作相关。这样即使其他组件有较慢的重叠异步操作,当前组件的乐观UI也能正确还原。 --- packages/react-reconciler/src/ReactFiberHooks.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 29c83c7d7263..d418872de8eb 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1469,14 +1469,15 @@ 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 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 action 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. 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 = { From 684ee57cb8e45b0d1d764ad471b814ad4a18b053 Mon Sep 17 00:00:00 2001 From: CGW406 <13565294+cgw406@user.noreply.gitee.com> Date: Wed, 22 Apr 2026 13:41:44 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(useOptimistic):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B9=90=E8=A7=82=E6=9B=B4=E6=96=B0=E5=9C=A8=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=B8=AD=E7=9A=84=E9=9A=94=E7=A6=BB=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保乐观更新的回滚机制仅与触发它的特定操作相关联,而不是被全局异步操作阻塞。添加回归测试验证组件B的乐观UI在自身操作完成时回滚,而不受组件A慢速操作的影响。 --- .../react-reconciler/src/ReactFiberHooks.js | 19 ++- .../src/__tests__/ReactAsyncActions-test.js | 158 ++++++++++++++++++ 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index d418872de8eb..dcc74d529f84 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1470,13 +1470,18 @@ function updateReducerImpl( // has finished. Pretend the update doesn't exist by skipping // over it. // - // Note: We 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 action 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. + // 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; continue; } else { 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 + , + ); + }, + ); });