@samitouri / QOS-React-1 / commits / 75c1bd7ee7

Prerendering support for useDeferredValue (#27512)

### Based on #27509 Revealing a prerendered tree (hidden -> visible) is considered the same as mounting a brand new tree. So, when an initialValue argument is passed to useDeferredValue, and it's prerendered inside a hidden tree, we should first prerender the initial value. After the initial value has been prerendered, we switch to prerendering the final one. This is the same sequence that we use when mounting new visible tree. Depending on how much prerendering work has been finished by the time the tree is revealed, we may or may not be able to skip all the way to the final value. This means we get the benefits of both prerendering and preview states: if we have enough resources to prerender the whole thing, we do that. If we don't, we have a preview state to show for immediate feedback.

Andrew Clark committed Oct 17, 2023 at 12:58 UTC 75c1bd7ee7e4a87a4dd0f560e157c57957ef823b
2 files changed +220 -38
packages/react-reconciler/src/ReactFiberHooks.js
+34 -38
@@ -150,6 +150,7 @@ import {
150 } from './ReactFiberAsyncAction';
151 import {HostTransitionContext} from './ReactFiberHostContext';
152 import {requestTransitionLane} from './ReactFiberRootScheduler';
153 +import {isCurrentTreeHidden} from './ReactFiberHiddenContext';
154
155 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
156
@@ -2688,12 +2689,6 @@ function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
2689 );
2690 markSkippedUpdateLanes(deferredLane);
2691
2691 - // Set this to true to indicate that the rendered value is inconsistent
2692 - // from the latest value. The name "baseState" doesn't really match how we
2693 - // use it because we're reusing a state hook field instead of creating a
2694 - // new one.
2695 - hook.baseState = true;
2696 -
2692 return initialValue;
2693 } else {
2694 hook.memoizedState = value;
@@ -2705,17 +2700,33 @@ function updateDeferredValueImpl<T>(
2700 hook: Hook,
2701 prevValue: T,
2702 value: T,
2708 - initialValue: ?T,
2703 + initialValue?: T,
2704 ): T {
2710 - // TODO: We should also check if this component is going from
2711 - // hidden -> visible. If so, it should use the initialValue arg.
2705 + if (is(value, prevValue)) {
2706 + // The incoming value is referentially identical to the currently rendered
2707 + // value, so we can bail out quickly.
2708 + return value;
2709 + } else {
2710 + // Received a new value that's different from the current value.
2711 +
2712 + // Check if we're inside a hidden tree
2713 + if (isCurrentTreeHidden()) {
2714 + // Revealing a prerendered tree is considered the same as mounting new
2715 + // one, so we reuse the "mount" path in this case.
2716 + const resultValue = mountDeferredValueImpl(hook, value, initialValue);
2717 + // Unlike during an actual mount, we need to mark this as an update if
2718 + // the value changed.
2719 + if (!is(resultValue, prevValue)) {
2720 + markWorkInProgressReceivedUpdate();
2721 + }
2722 + return resultValue;
2723 + }
2724
2713 - const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
2714 - if (shouldDeferValue) {
2715 - // This is an urgent update. If the value has changed, keep using the
2716 - // previous value and spawn a deferred render to update it later.
2725 + const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
2726 + if (shouldDeferValue) {
2727 + // This is an urgent update. Since the value has changed, keep using the
2728 + // previous value and spawn a deferred render to update it later.
2729
2718 - if (!is(value, prevValue)) {
2730 // Schedule a deferred render
2731 const deferredLane = requestDeferredLane();
2732 currentlyRenderingFiber.lanes = mergeLanes(
@@ -2724,33 +2735,18 @@ function updateDeferredValueImpl<T>(
2735 );
2736 markSkippedUpdateLanes(deferredLane);
2737
2727 - // Set this to true to indicate that the rendered value is inconsistent
2728 - // from the latest value. The name "baseState" doesn't really match how we
2729 - // use it because we're reusing a state hook field instead of creating a
2730 - // new one.
2731 - hook.baseState = true;
2732 - }
2733 -
2734 - // Reuse the previous value
2735 - return prevValue;
2736 - } else {
2737 - // This is not an urgent update, so we can use the latest value regardless
2738 - // of what it is. No need to defer it.
2738 + // Reuse the previous value. We do not need to mark this as an update,
2739 + // because we did not render a new value.
2740 + return prevValue;
2741 + } else {
2742 + // This is not an urgent update, so we can use the latest value regardless
2743 + // of what it is. No need to defer it.
2744
2740 - // However, if we're currently inside a spawned render, then we need to mark
2741 - // this as an update to prevent the fiber from bailing out.
2742 - //
2743 - // `baseState` is true when the current value is different from the rendered
2744 - // value. The name doesn't really match how we use it because we're reusing
2745 - // a state hook field instead of creating a new one.
2746 - if (hook.baseState) {
2747 - // Flip this back to false.
2748 - hook.baseState = false;
2745 + // Mark this as an update to prevent the fiber from bailing out.
2746 markWorkInProgressReceivedUpdate();
2747 + hook.memoizedState = value;
2748 + return value;
2749 }
2751 -
2752 - hook.memoizedState = value;
2753 - return value;
2750 }
2751 }
2752
packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
+186
@@ -616,4 +616,190 @@ describe('ReactDeferredValue', () => {
616 assertLog([]);
617 expect(root).toMatchRenderedOutput(<div>Final</div>);
618 });
619 +
620 + // @gate enableUseDeferredValueInitialArg
621 + // @gate enableOffscreen
622 + it('useDeferredValue can prerender the initial value inside a hidden tree', async () => {
623 + function App({text}) {
624 + const renderedText = useDeferredValue(text, `Preview [${text}]`);
625 + return (
626 + <div>
627 + <Text text={renderedText} />
628 + </div>
629 + );
630 + }
631 +
632 + let revealContent;
633 + function Container({children}) {
634 + const [shouldShow, setState] = useState(false);
635 + revealContent = () => setState(true);
636 + return (
637 + <Offscreen mode={shouldShow ? 'visible' : 'hidden'}>
638 + {children}
639 + </Offscreen>
640 + );
641 + }
642 +
643 + const root = ReactNoop.createRoot();
644 +
645 + // Prerender some content
646 + await act(() => {
647 + root.render(
648 + <Container>
649 + <App text="A" />
650 + </Container>,
651 + );
652 + });
653 + assertLog(['Preview [A]', 'A']);
654 + expect(root).toMatchRenderedOutput(<div hidden={true}>A</div>);
655 +
656 + await act(async () => {
657 + // While the tree is still hidden, update the pre-rendered tree.
658 + root.render(
659 + <Container>
660 + <App text="B" />
661 + </Container>,
662 + );
663 + // We should switch to pre-rendering the new preview.
664 + await waitForPaint(['Preview [B]']);
665 + expect(root).toMatchRenderedOutput(<div hidden={true}>Preview [B]</div>);
666 +
667 + // Before the prerender is complete, reveal the hidden tree. Because we
668 + // consider revealing a hidden tree to be the same as mounting a new one,
669 + // we should not skip the preview state.
670 + revealContent();
671 + // Because the preview state was already prerendered, we can reveal it
672 + // without any addditional work.
673 + await waitForPaint([]);
674 + expect(root).toMatchRenderedOutput(<div>Preview [B]</div>);
675 + });
676 + // Finally, finish rendering the final value.
677 + assertLog(['B']);
678 + expect(root).toMatchRenderedOutput(<div>B</div>);
679 + });
680 +
681 + // @gate enableUseDeferredValueInitialArg
682 + // @gate enableOffscreen
683 + it(
684 + 'useDeferredValue skips the preview state when revealing a hidden tree ' +
685 + 'if the final value is referentially identical',
686 + async () => {
687 + function App({text}) {
688 + const renderedText = useDeferredValue(text, `Preview [${text}]`);
689 + return (
690 + <div>
691 + <Text text={renderedText} />
692 + </div>
693 + );
694 + }
695 +
696 + function Container({text, shouldShow}) {
697 + return (
698 + <Offscreen mode={shouldShow ? 'visible' : 'hidden'}>
699 + <App text={text} />
700 + </Offscreen>
701 + );
702 + }
703 +
704 + const root = ReactNoop.createRoot();
705 +
706 + // Prerender some content
707 + await act(() => root.render(<Container text="A" shouldShow={false} />));
708 + assertLog(['Preview [A]', 'A']);
709 + expect(root).toMatchRenderedOutput(<div hidden={true}>A</div>);
710 +
711 + // Reveal the prerendered tree. Because the final value is referentially
712 + // equal to what was already prerendered, we can skip the preview state
713 + // and go straight to the final one. The practical upshot of this is
714 + // that we can completely prerender the final value without having to
715 + // do additional rendering work when the tree is revealed.
716 + await act(() => root.render(<Container text="A" shouldShow={true} />));
717 + assertLog(['A']);
718 + expect(root).toMatchRenderedOutput(<div>A</div>);
719 + },
720 + );
721 +
722 + // @gate enableUseDeferredValueInitialArg
723 + // @gate enableOffscreen
724 + it(
725 + 'useDeferredValue does not skip the preview state when revealing a ' +
726 + 'hidden tree if the final value is different from the currently rendered one',
727 + async () => {
728 + function App({text}) {
729 + const renderedText = useDeferredValue(text, `Preview [${text}]`);
730 + return (
731 + <div>
732 + <Text text={renderedText} />
733 + </div>
734 + );
735 + }
736 +
737 + function Container({text, shouldShow}) {
738 + return (
739 + <Offscreen mode={shouldShow ? 'visible' : 'hidden'}>
740 + <App text={text} />
741 + </Offscreen>
742 + );
743 + }
744 +
745 + const root = ReactNoop.createRoot();
746 +
747 + // Prerender some content
748 + await act(() => root.render(<Container text="A" shouldShow={false} />));
749 + assertLog(['Preview [A]', 'A']);
750 + expect(root).toMatchRenderedOutput(<div hidden={true}>A</div>);
751 +
752 + // Reveal the prerendered tree. Because the final value is different from
753 + // what was already prerendered, we can't bail out. Since we treat
754 + // revealing a hidden tree the same as a new mount, show the preview state
755 + // before switching to the final one.
756 + await act(async () => {
757 + root.render(<Container text="B" shouldShow={true} />);
758 + // First commit the preview state
759 + await waitForPaint(['Preview [B]']);
760 + expect(root).toMatchRenderedOutput(<div>Preview [B]</div>);
761 + });
762 + // Then switch to the final state
763 + assertLog(['B']);
764 + expect(root).toMatchRenderedOutput(<div>B</div>);
765 + },
766 + );
767 +
768 + // @gate enableOffscreen
769 + it(
770 + 'useDeferredValue does not show "previous" value when revealing a hidden ' +
771 + 'tree (no initial value)',
772 + async () => {
773 + function App({text}) {
774 + const renderedText = useDeferredValue(text);
775 + return (
776 + <div>
777 + <Text text={renderedText} />
778 + </div>
779 + );
780 + }
781 +
782 + function Container({text, shouldShow}) {
783 + return (
784 + <Offscreen mode={shouldShow ? 'visible' : 'hidden'}>
785 + <App text={text} />
786 + </Offscreen>
787 + );
788 + }
789 +
790 + const root = ReactNoop.createRoot();
791 +
792 + // Prerender some content
793 + await act(() => root.render(<Container text="A" shouldShow={false} />));
794 + assertLog(['A']);
795 + expect(root).toMatchRenderedOutput(<div hidden={true}>A</div>);
796 +
797 + // Update the prerendered tree and reveal it at the same time. Even though
798 + // this is a sync update, we should update B immediately rather than stay
799 + // on the old value (A), because conceptually this is a new tree.
800 + await act(() => root.render(<Container text="B" shouldShow={true} />));
801 + assertLog(['B']);
802 + expect(root).toMatchRenderedOutput(<div>B</div>);
803 + },
804 + );
805 });