Skip to content

Commit d17c080

Browse files
acdliteAndyPengc12
authored andcommitted
Prerendering support for useDeferredValue (facebook#27512)
### Based on facebook#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.
1 parent 1968a87 commit d17c080

File tree

2 files changed

+220
-38
lines changed

2 files changed

+220
-38
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ import {
150150
} from './ReactFiberAsyncAction';
151151
import {HostTransitionContext} from './ReactFiberHostContext';
152152
import {requestTransitionLane} from './ReactFiberRootScheduler';
153+
import {isCurrentTreeHidden} from './ReactFiberHiddenContext';
153154

154155
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
155156

@@ -2688,12 +2689,6 @@ function mountDeferredValueImpl<T>(hook: Hook, value: T, initialValue?: T): T {
26882689
);
26892690
markSkippedUpdateLanes(deferredLane);
26902691

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-
26972692
return initialValue;
26982693
} else {
26992694
hook.memoizedState = value;
@@ -2705,17 +2700,33 @@ function updateDeferredValueImpl<T>(
27052700
hook: Hook,
27062701
prevValue: T,
27072702
value: T,
2708-
initialValue: ?T,
2703+
initialValue?: T,
27092704
): 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+
}
27122724

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.
27172729

2718-
if (!is(value, prevValue)) {
27192730
// Schedule a deferred render
27202731
const deferredLane = requestDeferredLane();
27212732
currentlyRenderingFiber.lanes = mergeLanes(
@@ -2724,33 +2735,18 @@ function updateDeferredValueImpl<T>(
27242735
);
27252736
markSkippedUpdateLanes(deferredLane);
27262737

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.
27392744

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.
27492746
markWorkInProgressReceivedUpdate();
2747+
hook.memoizedState = value;
2748+
return value;
27502749
}
2751-
2752-
hook.memoizedState = value;
2753-
return value;
27542750
}
27552751
}
27562752

packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,4 +616,190 @@ describe('ReactDeferredValue', () => {
616616
assertLog([]);
617617
expect(root).toMatchRenderedOutput(<div>Final</div>);
618618
});
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+
);
619805
});

0 commit comments

Comments
 (0)