@samitouri / QOS-React / commits / 6913ea4d28

[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 ( <div> <span>{use(readCache(`${label} ${count}`))} </span> <Button action={() => { setCount((c) => c + 1); }} > Next {label} </Button> </div> ); } ``` ```js export default function App() { return ( <> <Counter label="A" /> <Counter label="B" /> </> ); } ``` ### 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.

Ricky committed Feb 4, 2026 at 13:58 UTC 6913ea4d28229d066603bff9fc1170334e151a4a
12 files changed +517 -9
packages/react-reconciler/src/ReactFiberLane.js
+4
@@ -29,6 +29,7 @@ import {
29 disableLegacyMode,
30 enableDefaultTransitionIndicator,
31 enableGestureTransition,
32 + enableParallelTransitions,
33 } from 'shared/ReactFeatureFlags';
34 import {isDevToolsPresent} from './ReactFiberDevToolsHook';
35 import {clz32} from './clz32';
@@ -208,6 +209,9 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
209 case TransitionLane8:
210 case TransitionLane9:
211 case TransitionLane10:
212 + if (enableParallelTransitions) {
213 + return getHighestPriorityLane(lanes);
214 + }
215 return lanes & TransitionUpdateLanes;
216 case TransitionLane11:
217 case TransitionLane12:
packages/react-reconciler/src/ReactFiberWorkLoop.js
+6
@@ -58,6 +58,7 @@ import {
58 enableViewTransition,
59 enableGestureTransition,
60 enableDefaultTransitionIndicator,
61 + enableParallelTransitions,
62 } from 'shared/ReactFeatureFlags';
63 import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
64 import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -1777,6 +1778,11 @@ function markRootSuspended(
1778 spawnedLane: Lane,
1779 didAttemptEntireTree: boolean,
1780 ) {
1781 + if (enableParallelTransitions) {
1782 + // When suspending, we should always mark the entangled lanes as suspended.
1783 + suspendedLanes = getEntangledLanes(root, suspendedLanes);
1784 + }
1785 +
1786 // When suspending, we should always exclude lanes that were pinged or (more
1787 // rarely, since we try to avoid it) updated during the render phase.
1788 suspendedLanes = removeLanes(suspendedLanes, workInProgressRootPingedLanes);
packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
+184 -9
@@ -420,9 +420,13 @@ describe('ReactDeferredValue', () => {
420 // The initial value suspended, so we attempt the final value, which
421 // also suspends.
422 'Suspend! [Final]',
423 - // pre-warming
424 - 'Suspend! [Loading...]',
425 - 'Suspend! [Final]',
423 + ...(gate('enableParallelTransitions')
424 + ? []
425 + : [
426 + // Existing bug: Unnecessary pre-warm.
427 + 'Suspend! [Loading...]',
428 + 'Suspend! [Final]',
429 + ]),
430 ]);
431 expect(root).toMatchRenderedOutput(null);
432
@@ -439,6 +443,171 @@ describe('ReactDeferredValue', () => {
443 },
444 );
445
446 + it(
447 + 'if a suspended render spawns a deferred task that suspends on a sibling, ' +
448 + 'we can finish the original task if the original sibling loads first',
449 + async () => {
450 + function App() {
451 + const deferredText = useDeferredValue(`Final`, `Loading...`);
452 + return (
453 + <>
454 + <AsyncText text={deferredText} />{' '}
455 + <AsyncText text={`Sibling: ${deferredText}`} />
456 + </>
457 + );
458 + }
459 +
460 + const root = ReactNoop.createRoot();
461 + await act(() => root.render(<App text="a" />));
462 + assertLog([
463 + 'Suspend! [Loading...]',
464 + // The initial value suspended, so we attempt the final value, which
465 + // also suspends.
466 + 'Suspend! [Final]',
467 + 'Suspend! [Sibling: Final]',
468 + ...(gate('enableParallelTransitions')
469 + ? [
470 + // With parallel transitions,
471 + // we do not continue pre-warming.
472 + ]
473 + : [
474 + 'Suspend! [Loading...]',
475 + 'Suspend! [Sibling: Loading...]',
476 + 'Suspend! [Final]',
477 + 'Suspend! [Sibling: Final]',
478 + ]),
479 + ]);
480 + expect(root).toMatchRenderedOutput(null);
481 +
482 + // The final value loads, so we can skip the initial value entirely.
483 + await act(() => {
484 + resolveText('Final');
485 + });
486 + assertLog(['Final', 'Suspend! [Sibling: Final]']);
487 + expect(root).toMatchRenderedOutput(null);
488 +
489 + // The initial value resolves first, so we render that.
490 + await act(() => resolveText('Loading...'));
491 + assertLog([
492 + 'Loading...',
493 + 'Suspend! [Sibling: Loading...]',
494 + 'Final',
495 + 'Suspend! [Sibling: Final]',
496 + ...(gate('enableParallelTransitions')
497 + ? [
498 + // With parallel transitions,
499 + // we do not continue pre-warming.
500 + ]
501 + : [
502 + 'Loading...',
503 + 'Suspend! [Sibling: Loading...]',
504 + 'Final',
505 + 'Suspend! [Sibling: Final]',
506 + ]),
507 + ]);
508 + expect(root).toMatchRenderedOutput(null);
509 +
510 + // The Final sibling loads, we're unblocked and commit.
511 + await act(() => {
512 + resolveText('Sibling: Final');
513 + });
514 + assertLog(['Final', 'Sibling: Final']);
515 + expect(root).toMatchRenderedOutput('Final Sibling: Final');
516 +
517 + // We already rendered the Final value, so nothing happens
518 + await act(() => {
519 + resolveText('Sibling: Loading...');
520 + });
521 + assertLog([]);
522 + expect(root).toMatchRenderedOutput('Final Sibling: Final');
523 + },
524 + );
525 +
526 + it(
527 + 'if a suspended render spawns a deferred task that suspends on a sibling,' +
528 + ' we can switch to the deferred task without finishing the original one',
529 + async () => {
530 + function App() {
531 + const deferredText = useDeferredValue(`Final`, `Loading...`);
532 + return (
533 + <>
534 + <AsyncText text={deferredText} />{' '}
535 + <AsyncText text={`Sibling: ${deferredText}`} />
536 + </>
537 + );
538 + }
539 +
540 + const root = ReactNoop.createRoot();
541 + await act(() => root.render(<App text="a" />));
542 + assertLog([
543 + 'Suspend! [Loading...]',
544 + // The initial value suspended, so we attempt the final value, which
545 + // also suspends.
546 + 'Suspend! [Final]',
547 + 'Suspend! [Sibling: Final]',
548 + ...(gate('enableParallelTransitions')
549 + ? [
550 + // With parallel transitions,
551 + // we do not continue pre-warming.
552 + ]
553 + : [
554 + 'Suspend! [Loading...]',
555 + 'Suspend! [Sibling: Loading...]',
556 + 'Suspend! [Final]',
557 + 'Suspend! [Sibling: Final]',
558 + ]),
559 + ]);
560 + expect(root).toMatchRenderedOutput(null);
561 +
562 + // The final value loads, so we can skip the initial value entirely.
563 + await act(() => {
564 + resolveText('Final');
565 + });
566 + assertLog(['Final', 'Suspend! [Sibling: Final]']);
567 + expect(root).toMatchRenderedOutput(null);
568 +
569 + // The initial value resolves first, so we render that.
570 + await act(() => resolveText('Loading...'));
571 + assertLog([
572 + 'Loading...',
573 + 'Suspend! [Sibling: Loading...]',
574 + 'Final',
575 + 'Suspend! [Sibling: Final]',
576 + ...(gate('enableParallelTransitions')
577 + ? [
578 + // With parallel transitions,
579 + // we do not continue pre-warming.
580 + ]
581 + : [
582 + 'Loading...',
583 + 'Suspend! [Sibling: Loading...]',
584 + 'Final',
585 + 'Suspend! [Sibling: Final]',
586 + ]),
587 + ]);
588 + expect(root).toMatchRenderedOutput(null);
589 +
590 + // The initial sibling loads, we're unblocked and commit.
591 + await act(() => {
592 + resolveText('Sibling: Loading...');
593 + });
594 + assertLog([
595 + 'Loading...',
596 + 'Sibling: Loading...',
597 + 'Final',
598 + 'Suspend! [Sibling: Final]',
599 + ]);
600 + expect(root).toMatchRenderedOutput('Loading... Sibling: Loading...');
601 +
602 + // Now unblock the final sibling.
603 + await act(() => {
604 + resolveText('Sibling: Final');
605 + });
606 + assertLog(['Final', 'Sibling: Final']);
607 + expect(root).toMatchRenderedOutput('Final Sibling: Final');
608 + },
609 + );
610 +
611 it(
612 'if a suspended render spawns a deferred task, we can switch to the ' +
613 'deferred task without finishing the original one (no Suspense boundary, ' +
@@ -462,9 +631,12 @@ describe('ReactDeferredValue', () => {
631 // The initial value suspended, so we attempt the final value, which
632 // also suspends.
633 'Suspend! [Final]',
465 - // pre-warming
466 - 'Suspend! [Loading...]',
467 - 'Suspend! [Final]',
634 + ...(gate('enableParallelTransitions')
635 + ? [
636 + // With parallel transitions,
637 + // we do not continue pre-warming.
638 + ]
639 + : ['Suspend! [Loading...]', 'Suspend! [Final]']),
640 ]);
641 expect(root).toMatchRenderedOutput(null);
642
@@ -539,9 +711,12 @@ describe('ReactDeferredValue', () => {
711 // The initial value suspended, so we attempt the final value, which
712 // also suspends.
713 'Suspend! [Final]',
542 - // pre-warming
543 - 'Suspend! [Loading...]',
544 - 'Suspend! [Final]',
714 + ...(gate('enableParallelTransitions')
715 + ? [
716 + // With parallel transitions,
717 + // we do not continue pre-warming.
718 + ]
719 + : ['Suspend! [Loading...]', 'Suspend! [Final]']),
720 ]);
721 expect(root).toMatchRenderedOutput(null);
722
packages/react-reconciler/src/__tests__/ReactTransition-test.js
+313
@@ -209,6 +209,319 @@ describe('ReactTransition', () => {
209 expect(root).toMatchRenderedOutput('Async');
210 });
211
212 + // @gate enableLegacyCache
213 + it('when multiple transitions update different queues, they entangle', async () => {
214 + let setA;
215 + let startTransitionA;
216 + let setB;
217 + let startTransitionB;
218 + function A() {
219 + const [a, _setA] = useState(0);
220 + const [isPending, _startTransitionA] = useTransition();
221 + setA = _setA;
222 + startTransitionA = _startTransitionA;
223 +
224 + return (
225 + <span>
226 + {isPending && (
227 + <span>
228 + <Text text="Pending A..." />
229 + </span>
230 + )}
231 + <AsyncText text={`A: ${a}`} />
232 + </span>
233 + );
234 + }
235 +
236 + function B() {
237 + const [b, _setB] = useState(0);
238 + const [isPending, _startTransitionB] = useTransition();
239 + setB = _setB;
240 + startTransitionB = _startTransitionB;
241 +
242 + return (
243 + <span>
244 + {isPending && (
245 + <span>
246 + <Text text="Pending B..." />
247 + </span>
248 + )}
249 + <AsyncText text={`B: ${b}`} />
250 + </span>
251 + );
252 + }
253 + function App() {
254 + return (
255 + <>
256 + <Suspense fallback={<span>Loading A</span>}>
257 + <A />
258 + </Suspense>
259 + <Suspense fallback={<span>Loading B</span>}>
260 + <B />
261 + </Suspense>
262 + </>
263 + );
264 + }
265 +
266 + // Initial render
267 + const root = ReactNoop.createRoot();
268 + await act(() => {
269 + root.render(<App />);
270 + });
271 + assertLog([
272 + 'Suspend! [A: 0]',
273 + 'Suspend! [B: 0]',
274 + 'Suspend! [A: 0]',
275 + 'Suspend! [B: 0]',
276 + ]);
277 + expect(root).toMatchRenderedOutput(
278 + <>
279 + <span>Loading A</span>
280 + <span>Loading B</span>
281 + </>,
282 + );
283 +
284 + // Resolve
285 + await act(() => {
286 + resolveText('A: 0');
287 + resolveText('B: 0');
288 + });
289 + assertLog(['A: 0', 'B: 0']);
290 + expect(root).toMatchRenderedOutput(
291 + <>
292 + <span>A: 0</span>
293 + <span>B: 0</span>
294 + </>,
295 + );
296 +
297 + // Start transitioning A
298 + await act(() => {
299 + startTransitionA(() => {
300 + setA(1);
301 + });
302 + });
303 + assertLog(['Pending A...', 'A: 0', 'Suspend! [A: 1]']);
304 + expect(root).toMatchRenderedOutput(
305 + <>
306 + <span>
307 + <span>Pending A...</span>A: 0
308 + </span>
309 + <span>B: 0</span>
310 + </>,
311 + );
312 +
313 + // Start transitioning B
314 + await act(() => {
315 + startTransitionB(() => {
316 + setB(1);
317 + });
318 + });
319 + assertLog(['Pending B...', 'B: 0', 'Suspend! [A: 1]', 'Suspend! [B: 1]']);
320 + expect(root).toMatchRenderedOutput(
321 + <>
322 + <span>
323 + <span>Pending A...</span>A: 0
324 + </span>
325 + <span>
326 + <span>Pending B...</span>B: 0
327 + </span>
328 + </>,
329 + );
330 +
331 + // Resolve B
332 + await act(() => {
333 + resolveText('B: 1');
334 + });
335 + assertLog(
336 + gate('enableParallelTransitions')
337 + ? ['B: 1', 'Suspend! [A: 1]']
338 + : ['Suspend! [A: 1]', 'B: 1'],
339 + );
340 + expect(root).toMatchRenderedOutput(
341 + gate('enableParallelTransitions') ? (
342 + <>
343 + <span>
344 + <span>Pending A...</span>A: 0
345 + </span>
346 + <span>B: 1</span>
347 + </>
348 + ) : (
349 + <>
350 + <span>
351 + <span>Pending A...</span>A: 0
352 + </span>
353 + <span>
354 + <span>Pending B...</span>B: 0
355 + </span>
356 + </>
357 + ),
358 + );
359 +
360 + // Resolve A
361 + await act(() => {
362 + resolveText('A: 1');
363 + });
364 + assertLog(gate('enableParallelTransitions') ? ['A: 1'] : ['A: 1', 'B: 1']);
365 + expect(root).toMatchRenderedOutput(
366 + <>
367 + <span>A: 1</span>
368 + <span>B: 1</span>
369 + </>,
370 + );
371 + });
372 +
373 + // @gate enableLegacyCache
374 + it('when multiple transitions update different queues, but suspend the same boundary, they do entangle', async () => {
375 + let setA;
376 + let startTransitionA;
377 + let setB;
378 + let startTransitionB;
379 + function A() {
380 + const [a, _setA] = useState(0);
381 + const [isPending, _startTransitionA] = useTransition();
382 + setA = _setA;
383 + startTransitionA = _startTransitionA;
384 +
385 + return (
386 + <span>
387 + {isPending && (
388 + <span>
389 + <Text text="Pending A..." />
390 + </span>
391 + )}
392 + <AsyncText text={`A: ${a}`} />
393 + </span>
394 + );
395 + }
396 +
397 + function B() {
398 + const [b, _setB] = useState(0);
399 + const [isPending, _startTransitionB] = useTransition();
400 + setB = _setB;
401 + startTransitionB = _startTransitionB;
402 +
403 + return (
404 + <span>
405 + {isPending && (
406 + <span>
407 + <Text text="Pending B..." />
408 + </span>
409 + )}
410 + <AsyncText text={`B: ${b}`} />
411 + </span>
412 + );
413 + }
414 + function App() {
415 + return (
416 + <Suspense fallback={<span>Loading...</span>}>
417 + <A />
418 + <B />
419 + </Suspense>
420 + );
421 + }
422 +
423 + // Initial render
424 + const root = ReactNoop.createRoot();
425 + await act(() => {
426 + root.render(<App />);
427 + });
428 + assertLog([
429 + 'Suspend! [A: 0]',
430 + // pre-warming
431 + 'Suspend! [A: 0]',
432 + 'Suspend! [B: 0]',
433 + ]);
434 + expect(root).toMatchRenderedOutput(<span>Loading...</span>);
435 +
436 + // Resolve
437 + await act(() => {
438 + resolveText('A: 0');
439 + resolveText('B: 0');
440 + });
441 + assertLog(['A: 0', 'B: 0']);
442 + expect(root).toMatchRenderedOutput(
443 + <>
444 + <span>A: 0</span>
445 + <span>B: 0</span>
446 + </>,
447 + );
448 +
449 + // Start transitioning A
450 + await act(() => {
451 + startTransitionA(() => {
452 + setA(1);
453 + });
454 + });
455 + assertLog(['Pending A...', 'A: 0', 'Suspend! [A: 1]']);
456 + expect(root).toMatchRenderedOutput(
457 + <>
458 + <span>
459 + <span>Pending A...</span>A: 0
460 + </span>
461 + <span>B: 0</span>
462 + </>,
463 + );
464 +
465 + // Start transitioning B
466 + await act(() => {
467 + startTransitionB(() => {
468 + setB(1);
469 + });
470 + });
471 + assertLog(['Pending B...', 'B: 0', 'Suspend! [A: 1]', 'Suspend! [B: 1]']);
472 + expect(root).toMatchRenderedOutput(
473 + <>
474 + <span>
475 + <span>Pending A...</span>A: 0
476 + </span>
477 + <span>
478 + <span>Pending B...</span>B: 0
479 + </span>
480 + </>,
481 + );
482 +
483 + // Resolve B
484 + await act(() => {
485 + resolveText('B: 1');
486 + });
487 + assertLog(
488 + gate('enableParallelTransitions')
489 + ? ['B: 1', 'Suspend! [A: 1]']
490 + : ['Suspend! [A: 1]', 'B: 1'],
491 + );
492 + expect(root).toMatchRenderedOutput(
493 + gate('enableParallelTransitions') ? (
494 + <>
495 + <span>
496 + <span>Pending A...</span>A: 0
497 + </span>
498 + <span>B: 1</span>
499 + </>
500 + ) : (
501 + <>
502 + <span>
503 + <span>Pending A...</span>A: 0
504 + </span>
505 + <span>
506 + <span>Pending B...</span>B: 0
507 + </span>
508 + </>
509 + ),
510 + );
511 +
512 + // Resolve A
513 + await act(() => {
514 + resolveText('A: 1');
515 + });
516 + assertLog(gate('enableParallelTransitions') ? ['A: 1'] : ['A: 1', 'B: 1']);
517 + expect(root).toMatchRenderedOutput(
518 + <>
519 + <span>A: 1</span>
520 + <span>B: 1</span>
521 + </>,
522 + );
523 + });
524 +
525 // @gate enableLegacyCache
526 it(
527 'when multiple transitions update the same queue, only the most recent ' +
packages/shared/ReactFeatureFlags.js
+3
@@ -215,6 +215,9 @@ export const disableInputAttributeSyncing: boolean = false;
215 // Disables children for <textarea> elements
216 export const disableTextareaChildren: boolean = false;
217
218 +// Disables children for <textarea> elements
219 +export const enableParallelTransitions: boolean = true;
220 +
221 // -----------------------------------------------------------------------------
222 // Debugging and DevTools
223 // -----------------------------------------------------------------------------
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -83,6 +83,7 @@ export const enablePerformanceIssueReporting: boolean =
83 enableComponentPerformanceTrack;
84 export const enableInternalInstanceMap: boolean = false;
85 export const enableOptimisticKey: boolean = false;
86 +export const enableParallelTransitions: boolean = false;
87
88 // Flow magic to verify the exports of this file match the original version.
89 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -83,6 +83,7 @@ export const enableProfilerTimer: boolean = __PROFILE__;
83 export const enableProfilerCommitHooks: boolean = __PROFILE__;
84 export const enableProfilerNestedUpdatePhase: boolean = __PROFILE__;
85 export const enableUpdaterTracking: boolean = __PROFILE__;
86 +export const enableParallelTransitions: boolean = false;
87
88 // Flow magic to verify the exports of this file match the original version.
89 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -92,6 +92,7 @@ export const enableReactTestRendererWarning: boolean = true;
92 export const enableObjectFiber: boolean = false;
93
94 export const enableOptimisticKey: boolean = false;
95 +export const enableParallelTransitions: boolean = false;
96
97 // Flow magic to verify the exports of this file match the original version.
98 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -69,6 +69,7 @@ export const enableFragmentRefsInstanceHandles = false;
69 export const enableFragmentRefsTextNodes = false;
70 export const ownerStackLimit = 1e4;
71 export const enableOptimisticKey = false;
72 +export const enableParallelTransitions = false;
73
74 // Flow magic to verify the exports of this file match the original version.
75 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -84,6 +84,7 @@ export const ownerStackLimit = 1e4;
84 export const enableInternalInstanceMap: boolean = false;
85
86 export const enableOptimisticKey: boolean = false;
87 +export const enableParallelTransitions: boolean = false;
88
89 // Flow magic to verify the exports of this file match the original version.
90 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -37,6 +37,7 @@ export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__;
37 export const enableFragmentRefsTextNodes: boolean = __VARIANT__;
38 export const enableInternalInstanceMap: boolean = __VARIANT__;
39 export const enableTrustedTypesIntegration: boolean = __VARIANT__;
40 +export const enableParallelTransitions: boolean = __VARIANT__;
41
42 // TODO: These flags are hard-coded to the default values used in open source.
43 // Update the tests so that they pass in either mode, then set these
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -34,6 +34,7 @@ export const {
34 enableFragmentRefsScrollIntoView,
35 enableFragmentRefsTextNodes,
36 enableInternalInstanceMap,
37 + enableParallelTransitions,
38 } = dynamicFeatureFlags;
39
40 // On WWW, __EXPERIMENTAL__ is used for a new modern build.