From 6913ea4d28229d066603bff9fc1170334e151a4a Mon Sep 17 00:00:00 2001 From: Ricky Date: Wed, 4 Feb 2026 13:58:34 -0500 Subject: [PATCH] [flags] Add `enableParallelTransitions` (#35392) ## Overview Adds a feature flag `enableParallelTransitions` to experiment with engantling transitions less often. ## Motivation Currently we over-entangle transition lanes. It's a common misunderstanding that React entangles all transitions, always. We actually will complete transitions independently in many cases. For example, [this codepen](https://codepen.io/GabbeV/pen/pvyKBrM) from [@gabbev](https://bsky.app/profile/gabbev.bsky.social/post/3m6uq2abihk2x) shows transitions completing independently. However, in many cases we entangle when we don't need to, instead of letting the independent transitons complete independently. We still want to entangle for updates that happen on the same queue. ## Example As an example of what this flag would change, consider two independent counter components: ```js function Counter({ label }) { const [count, setCount] = useState(0); return (
{use(readCache(`${label} ${count}`))}
); } ``` ```js export default function App() { return ( <> ); } ``` ### Before The behavior today is to entange them, meaning they always commit together: https://github.com/user-attachments/assets/adead60e-8a98-4a20-a440-1efdf85b2142 ### After In this experiment, they will complete independently (if they don't depend on each other): https://github.com/user-attachments/assets/181632b5-3c92-4a29-a571-3637f3fab8cd ## Early Research This change is in early research, and is not in the experimental channel. We're going to experiment with this at Meta to understand how much of a breaking change, and how beneficial it is before commiting to shipping it in experimental and beyond. --- .../react-reconciler/src/ReactFiberLane.js | 4 + .../src/ReactFiberWorkLoop.js | 6 + .../src/__tests__/ReactDeferredValue-test.js | 193 ++++++++++- .../src/__tests__/ReactTransition-test.js | 313 ++++++++++++++++++ packages/shared/ReactFeatureFlags.js | 3 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 12 files changed, 517 insertions(+), 9 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index b54047cb4a..987f0338ad 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -29,6 +29,7 @@ import { disableLegacyMode, enableDefaultTransitionIndicator, enableGestureTransition, + enableParallelTransitions, } from 'shared/ReactFeatureFlags'; import {isDevToolsPresent} from './ReactFiberDevToolsHook'; import {clz32} from './clz32'; @@ -208,6 +209,9 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes { case TransitionLane8: case TransitionLane9: case TransitionLane10: + if (enableParallelTransitions) { + return getHighestPriorityLane(lanes); + } return lanes & TransitionUpdateLanes; case TransitionLane11: case TransitionLane12: diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index b03f5eff15..d055b271ad 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -58,6 +58,7 @@ import { enableViewTransition, enableGestureTransition, enableDefaultTransitionIndicator, + enableParallelTransitions, } from 'shared/ReactFeatureFlags'; import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset'; import ReactSharedInternals from 'shared/ReactSharedInternals'; @@ -1777,6 +1778,11 @@ function markRootSuspended( spawnedLane: Lane, didAttemptEntireTree: boolean, ) { + if (enableParallelTransitions) { + // When suspending, we should always mark the entangled lanes as suspended. + suspendedLanes = getEntangledLanes(root, suspendedLanes); + } + // When suspending, we should always exclude lanes that were pinged or (more // rarely, since we try to avoid it) updated during the render phase. suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes); diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js index e4fe796fd3..3a348307f4 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -420,9 +420,13 @@ describe('ReactDeferredValue', () => { // The initial value suspended, so we attempt the final value, which // also suspends. 'Suspend! [Final]', - // pre-warming - 'Suspend! [Loading...]', - 'Suspend! [Final]', + ...(gate('enableParallelTransitions') + ? [] + : [ + // Existing bug: Unnecessary pre-warm. + 'Suspend! [Loading...]', + 'Suspend! [Final]', + ]), ]); expect(root).toMatchRenderedOutput(null); @@ -439,6 +443,171 @@ describe('ReactDeferredValue', () => { }, ); + it( + 'if a suspended render spawns a deferred task that suspends on a sibling, ' + + 'we can finish the original task if the original sibling loads first', + async () => { + function App() { + const deferredText = useDeferredValue(`Final`, `Loading...`); + return ( + <> + {' '} + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog([ + 'Suspend! [Loading...]', + // The initial value suspended, so we attempt the final value, which + // also suspends. + 'Suspend! [Final]', + 'Suspend! [Sibling: Final]', + ...(gate('enableParallelTransitions') + ? [ + // With parallel transitions, + // we do not continue pre-warming. + ] + : [ + 'Suspend! [Loading...]', + 'Suspend! [Sibling: Loading...]', + 'Suspend! [Final]', + 'Suspend! [Sibling: Final]', + ]), + ]); + expect(root).toMatchRenderedOutput(null); + + // The final value loads, so we can skip the initial value entirely. + await act(() => { + resolveText('Final'); + }); + assertLog(['Final', 'Suspend! [Sibling: Final]']); + expect(root).toMatchRenderedOutput(null); + + // The initial value resolves first, so we render that. + await act(() => resolveText('Loading...')); + assertLog([ + 'Loading...', + 'Suspend! [Sibling: Loading...]', + 'Final', + 'Suspend! [Sibling: Final]', + ...(gate('enableParallelTransitions') + ? [ + // With parallel transitions, + // we do not continue pre-warming. + ] + : [ + 'Loading...', + 'Suspend! [Sibling: Loading...]', + 'Final', + 'Suspend! [Sibling: Final]', + ]), + ]); + expect(root).toMatchRenderedOutput(null); + + // The Final sibling loads, we're unblocked and commit. + await act(() => { + resolveText('Sibling: Final'); + }); + assertLog(['Final', 'Sibling: Final']); + expect(root).toMatchRenderedOutput('Final Sibling: Final'); + + // We already rendered the Final value, so nothing happens + await act(() => { + resolveText('Sibling: Loading...'); + }); + assertLog([]); + expect(root).toMatchRenderedOutput('Final Sibling: Final'); + }, + ); + + it( + 'if a suspended render spawns a deferred task that suspends on a sibling,' + + ' we can switch to the deferred task without finishing the original one', + async () => { + function App() { + const deferredText = useDeferredValue(`Final`, `Loading...`); + return ( + <> + {' '} + + + ); + } + + const root = ReactNoop.createRoot(); + await act(() => root.render()); + assertLog([ + 'Suspend! [Loading...]', + // The initial value suspended, so we attempt the final value, which + // also suspends. + 'Suspend! [Final]', + 'Suspend! [Sibling: Final]', + ...(gate('enableParallelTransitions') + ? [ + // With parallel transitions, + // we do not continue pre-warming. + ] + : [ + 'Suspend! [Loading...]', + 'Suspend! [Sibling: Loading...]', + 'Suspend! [Final]', + 'Suspend! [Sibling: Final]', + ]), + ]); + expect(root).toMatchRenderedOutput(null); + + // The final value loads, so we can skip the initial value entirely. + await act(() => { + resolveText('Final'); + }); + assertLog(['Final', 'Suspend! [Sibling: Final]']); + expect(root).toMatchRenderedOutput(null); + + // The initial value resolves first, so we render that. + await act(() => resolveText('Loading...')); + assertLog([ + 'Loading...', + 'Suspend! [Sibling: Loading...]', + 'Final', + 'Suspend! [Sibling: Final]', + ...(gate('enableParallelTransitions') + ? [ + // With parallel transitions, + // we do not continue pre-warming. + ] + : [ + 'Loading...', + 'Suspend! [Sibling: Loading...]', + 'Final', + 'Suspend! [Sibling: Final]', + ]), + ]); + expect(root).toMatchRenderedOutput(null); + + // The initial sibling loads, we're unblocked and commit. + await act(() => { + resolveText('Sibling: Loading...'); + }); + assertLog([ + 'Loading...', + 'Sibling: Loading...', + 'Final', + 'Suspend! [Sibling: Final]', + ]); + expect(root).toMatchRenderedOutput('Loading... Sibling: Loading...'); + + // Now unblock the final sibling. + await act(() => { + resolveText('Sibling: Final'); + }); + assertLog(['Final', 'Sibling: Final']); + expect(root).toMatchRenderedOutput('Final Sibling: Final'); + }, + ); + it( 'if a suspended render spawns a deferred task, we can switch to the ' + 'deferred task without finishing the original one (no Suspense boundary, ' + @@ -462,9 +631,12 @@ describe('ReactDeferredValue', () => { // The initial value suspended, so we attempt the final value, which // also suspends. 'Suspend! [Final]', - // pre-warming - 'Suspend! [Loading...]', - 'Suspend! [Final]', + ...(gate('enableParallelTransitions') + ? [ + // With parallel transitions, + // we do not continue pre-warming. + ] + : ['Suspend! [Loading...]', 'Suspend! [Final]']), ]); expect(root).toMatchRenderedOutput(null); @@ -539,9 +711,12 @@ describe('ReactDeferredValue', () => { // The initial value suspended, so we attempt the final value, which // also suspends. 'Suspend! [Final]', - // pre-warming - 'Suspend! [Loading...]', - 'Suspend! [Final]', + ...(gate('enableParallelTransitions') + ? [ + // With parallel transitions, + // we do not continue pre-warming. + ] + : ['Suspend! [Loading...]', 'Suspend! [Final]']), ]); expect(root).toMatchRenderedOutput(null); diff --git a/packages/react-reconciler/src/__tests__/ReactTransition-test.js b/packages/react-reconciler/src/__tests__/ReactTransition-test.js index d3ad2e6138..7a96201806 100644 --- a/packages/react-reconciler/src/__tests__/ReactTransition-test.js +++ b/packages/react-reconciler/src/__tests__/ReactTransition-test.js @@ -209,6 +209,319 @@ describe('ReactTransition', () => { expect(root).toMatchRenderedOutput('Async'); }); + // @gate enableLegacyCache + it('when multiple transitions update different queues, they entangle', async () => { + let setA; + let startTransitionA; + let setB; + let startTransitionB; + function A() { + const [a, _setA] = useState(0); + const [isPending, _startTransitionA] = useTransition(); + setA = _setA; + startTransitionA = _startTransitionA; + + return ( + + {isPending && ( + + + + )} + + + ); + } + + function B() { + const [b, _setB] = useState(0); + const [isPending, _startTransitionB] = useTransition(); + setB = _setB; + startTransitionB = _startTransitionB; + + return ( + + {isPending && ( + + + + )} + + + ); + } + function App() { + return ( + <> + Loading A}> + + + Loading B}> + + + + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog([ + 'Suspend! [A: 0]', + 'Suspend! [B: 0]', + 'Suspend! [A: 0]', + 'Suspend! [B: 0]', + ]); + expect(root).toMatchRenderedOutput( + <> + Loading A + Loading B + , + ); + + // Resolve + await act(() => { + resolveText('A: 0'); + resolveText('B: 0'); + }); + assertLog(['A: 0', 'B: 0']); + expect(root).toMatchRenderedOutput( + <> + A: 0 + B: 0 + , + ); + + // Start transitioning A + await act(() => { + startTransitionA(() => { + setA(1); + }); + }); + assertLog(['Pending A...', 'A: 0', 'Suspend! [A: 1]']); + expect(root).toMatchRenderedOutput( + <> + + Pending A...A: 0 + + B: 0 + , + ); + + // Start transitioning B + await act(() => { + startTransitionB(() => { + setB(1); + }); + }); + assertLog(['Pending B...', 'B: 0', 'Suspend! [A: 1]', 'Suspend! [B: 1]']); + expect(root).toMatchRenderedOutput( + <> + + Pending A...A: 0 + + + Pending B...B: 0 + + , + ); + + // Resolve B + await act(() => { + resolveText('B: 1'); + }); + assertLog( + gate('enableParallelTransitions') + ? ['B: 1', 'Suspend! [A: 1]'] + : ['Suspend! [A: 1]', 'B: 1'], + ); + expect(root).toMatchRenderedOutput( + gate('enableParallelTransitions') ? ( + <> + + Pending A...A: 0 + + B: 1 + + ) : ( + <> + + Pending A...A: 0 + + + Pending B...B: 0 + + + ), + ); + + // Resolve A + await act(() => { + resolveText('A: 1'); + }); + assertLog(gate('enableParallelTransitions') ? ['A: 1'] : ['A: 1', 'B: 1']); + expect(root).toMatchRenderedOutput( + <> + A: 1 + B: 1 + , + ); + }); + + // @gate enableLegacyCache + it('when multiple transitions update different queues, but suspend the same boundary, they do entangle', async () => { + let setA; + let startTransitionA; + let setB; + let startTransitionB; + function A() { + const [a, _setA] = useState(0); + const [isPending, _startTransitionA] = useTransition(); + setA = _setA; + startTransitionA = _startTransitionA; + + return ( + + {isPending && ( + + + + )} + + + ); + } + + function B() { + const [b, _setB] = useState(0); + const [isPending, _startTransitionB] = useTransition(); + setB = _setB; + startTransitionB = _startTransitionB; + + return ( + + {isPending && ( + + + + )} + + + ); + } + function App() { + return ( + Loading...}> + + + + ); + } + + // Initial render + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + assertLog([ + 'Suspend! [A: 0]', + // pre-warming + 'Suspend! [A: 0]', + 'Suspend! [B: 0]', + ]); + expect(root).toMatchRenderedOutput(Loading...); + + // Resolve + await act(() => { + resolveText('A: 0'); + resolveText('B: 0'); + }); + assertLog(['A: 0', 'B: 0']); + expect(root).toMatchRenderedOutput( + <> + A: 0 + B: 0 + , + ); + + // Start transitioning A + await act(() => { + startTransitionA(() => { + setA(1); + }); + }); + assertLog(['Pending A...', 'A: 0', 'Suspend! [A: 1]']); + expect(root).toMatchRenderedOutput( + <> + + Pending A...A: 0 + + B: 0 + , + ); + + // Start transitioning B + await act(() => { + startTransitionB(() => { + setB(1); + }); + }); + assertLog(['Pending B...', 'B: 0', 'Suspend! [A: 1]', 'Suspend! [B: 1]']); + expect(root).toMatchRenderedOutput( + <> + + Pending A...A: 0 + + + Pending B...B: 0 + + , + ); + + // Resolve B + await act(() => { + resolveText('B: 1'); + }); + assertLog( + gate('enableParallelTransitions') + ? ['B: 1', 'Suspend! [A: 1]'] + : ['Suspend! [A: 1]', 'B: 1'], + ); + expect(root).toMatchRenderedOutput( + gate('enableParallelTransitions') ? ( + <> + + Pending A...A: 0 + + B: 1 + + ) : ( + <> + + Pending A...A: 0 + + + Pending B...B: 0 + + + ), + ); + + // Resolve A + await act(() => { + resolveText('A: 1'); + }); + assertLog(gate('enableParallelTransitions') ? ['A: 1'] : ['A: 1', 'B: 1']); + expect(root).toMatchRenderedOutput( + <> + A: 1 + B: 1 + , + ); + }); + // @gate enableLegacyCache it( 'when multiple transitions update the same queue, only the most recent ' + diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 113370d1eb..affa741aa9 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -215,6 +215,9 @@ export const disableInputAttributeSyncing: boolean = false; // Disables children for