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