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