Skip to content

Commit 5d0c3c6

Browse files
authored
[Partial Hydration] Render client-only content at normal priority (#15061)
* Split props changing from permanent fallback state These will need different logic. In this commit, no logic has changed, only moved. * Delete terminal fallback content in first pass If the dehydrated suspense boundary's fallback content is terminal there is nothing to show. We need to get actual content on the screen soon. If we deprioritize that work to offscreen, then the timeout heuristics will be wrong. Therefore, if we have no current and we're already at terminal fallback state we'll immediately schedule a deletion and upgrade to real suspense. * Show failing case when there is another wrapper boundary * Revert "Delete terminal fallback content in first pass" This reverts commit ad67ba8. * Use the new approach of leaving work at normal pri to replace fallback
1 parent 6a4a261 commit 5d0c3c6

File tree

2 files changed

+198
-42
lines changed

2 files changed

+198
-42
lines changed

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,126 @@ describe('ReactDOMServerPartialHydration', () => {
703703
expect(ref.current).toBe(span);
704704
});
705705

706+
it('replaces the fallback within the maxDuration if there is a nested suspense', async () => {
707+
let suspend = false;
708+
let promise = new Promise(resolvePromise => {});
709+
let ref = React.createRef();
710+
711+
function Child() {
712+
if (suspend) {
713+
throw promise;
714+
} else {
715+
return 'Hello';
716+
}
717+
}
718+
719+
function InnerChild() {
720+
// Always suspends indefinitely
721+
throw promise;
722+
}
723+
724+
function App() {
725+
return (
726+
<div>
727+
<Suspense fallback="Loading..." maxDuration={100}>
728+
<span ref={ref}>
729+
<Child />
730+
</span>
731+
<Suspense fallback={null}>
732+
<InnerChild />
733+
</Suspense>
734+
</Suspense>
735+
</div>
736+
);
737+
}
738+
739+
// First we render the final HTML. With the streaming renderer
740+
// this may have suspense points on the server but here we want
741+
// to test the completed HTML. Don't suspend on the server.
742+
suspend = true;
743+
let finalHTML = ReactDOMServer.renderToString(<App />);
744+
let container = document.createElement('div');
745+
container.innerHTML = finalHTML;
746+
747+
expect(container.getElementsByTagName('span').length).toBe(0);
748+
749+
// On the client we have the data available quickly for some reason.
750+
suspend = false;
751+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
752+
root.render(<App />);
753+
Scheduler.flushAll();
754+
// This will have exceeded the maxDuration so we should timeout.
755+
jest.advanceTimersByTime(500);
756+
// The boundary should longer be suspended for the middle content
757+
// even though the inner boundary is still suspended.
758+
759+
expect(container.textContent).toBe('Hello');
760+
761+
let span = container.getElementsByTagName('span')[0];
762+
expect(ref.current).toBe(span);
763+
});
764+
765+
it('replaces the fallback within the maxDuration if there is a nested suspense in a nested suspense', async () => {
766+
let suspend = false;
767+
let promise = new Promise(resolvePromise => {});
768+
let ref = React.createRef();
769+
770+
function Child() {
771+
if (suspend) {
772+
throw promise;
773+
} else {
774+
return 'Hello';
775+
}
776+
}
777+
778+
function InnerChild() {
779+
// Always suspends indefinitely
780+
throw promise;
781+
}
782+
783+
function App() {
784+
return (
785+
<div>
786+
<Suspense fallback="Another layer">
787+
<Suspense fallback="Loading..." maxDuration={100}>
788+
<span ref={ref}>
789+
<Child />
790+
</span>
791+
<Suspense fallback={null}>
792+
<InnerChild />
793+
</Suspense>
794+
</Suspense>
795+
</Suspense>
796+
</div>
797+
);
798+
}
799+
800+
// First we render the final HTML. With the streaming renderer
801+
// this may have suspense points on the server but here we want
802+
// to test the completed HTML. Don't suspend on the server.
803+
suspend = true;
804+
let finalHTML = ReactDOMServer.renderToString(<App />);
805+
let container = document.createElement('div');
806+
container.innerHTML = finalHTML;
807+
808+
expect(container.getElementsByTagName('span').length).toBe(0);
809+
810+
// On the client we have the data available quickly for some reason.
811+
suspend = false;
812+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
813+
root.render(<App />);
814+
Scheduler.flushAll();
815+
// This will have exceeded the maxDuration so we should timeout.
816+
jest.advanceTimersByTime(500);
817+
// The boundary should longer be suspended for the middle content
818+
// even though the inner boundary is still suspended.
819+
820+
expect(container.textContent).toBe('Hello');
821+
822+
let span = container.getElementsByTagName('span')[0];
823+
expect(ref.current).toBe(span);
824+
});
825+
706826
it('waits for pending content to come in from the server and then hydrates it', async () => {
707827
let suspend = false;
708828
let promise = new Promise(resolvePromise => {});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 78 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,11 @@ import {
7474
cloneChildFibers,
7575
} from './ReactChildFiber';
7676
import {processUpdateQueue} from './ReactUpdateQueue';
77-
import {NoWork, Never} from './ReactFiberExpirationTime';
77+
import {
78+
NoWork,
79+
Never,
80+
computeAsyncExpiration,
81+
} from './ReactFiberExpirationTime';
7882
import {
7983
ConcurrentMode,
8084
NoContext,
@@ -133,7 +137,7 @@ import {
133137
createWorkInProgress,
134138
isSimpleFunctionComponent,
135139
} from './ReactFiber';
136-
import {retryTimedOutBoundary} from './ReactFiberScheduler';
140+
import {requestCurrentTime, retryTimedOutBoundary} from './ReactFiberScheduler';
137141

138142
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
139143

@@ -1631,15 +1635,71 @@ function updateSuspenseComponent(
16311635
return next;
16321636
}
16331637

1638+
function retrySuspenseComponentWithoutHydrating(
1639+
current: Fiber,
1640+
workInProgress: Fiber,
1641+
renderExpirationTime: ExpirationTime,
1642+
) {
1643+
// Detach from the current dehydrated boundary.
1644+
current.alternate = null;
1645+
workInProgress.alternate = null;
1646+
1647+
// Insert a deletion in the effect list.
1648+
let returnFiber = workInProgress.return;
1649+
invariant(
1650+
returnFiber !== null,
1651+
'Suspense boundaries are never on the root. ' +
1652+
'This is probably a bug in React.',
1653+
);
1654+
const last = returnFiber.lastEffect;
1655+
if (last !== null) {
1656+
last.nextEffect = current;
1657+
returnFiber.lastEffect = current;
1658+
} else {
1659+
returnFiber.firstEffect = returnFiber.lastEffect = current;
1660+
}
1661+
current.nextEffect = null;
1662+
current.effectTag = Deletion;
1663+
1664+
// Upgrade this work in progress to a real Suspense component.
1665+
workInProgress.tag = SuspenseComponent;
1666+
workInProgress.stateNode = null;
1667+
workInProgress.memoizedState = null;
1668+
// This is now an insertion.
1669+
workInProgress.effectTag |= Placement;
1670+
// Retry as a real Suspense component.
1671+
return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
1672+
}
1673+
16341674
function updateDehydratedSuspenseComponent(
16351675
current: Fiber | null,
16361676
workInProgress: Fiber,
16371677
renderExpirationTime: ExpirationTime,
16381678
) {
1679+
const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
16391680
if (current === null) {
16401681
// During the first pass, we'll bail out and not drill into the children.
16411682
// Instead, we'll leave the content in place and try to hydrate it later.
1642-
workInProgress.expirationTime = Never;
1683+
if (isSuspenseInstanceFallback(suspenseInstance)) {
1684+
// This is a client-only boundary. Since we won't get any content from the server
1685+
// for this, we need to schedule that at a higher priority based on when it would
1686+
// have timed out. In theory we could render it in this pass but it would have the
1687+
// wrong priority associated with it and will prevent hydration of parent path.
1688+
// Instead, we'll leave work left on it to render it in a separate commit.
1689+
1690+
// TODO This time should be the time at which the server rendered response that is
1691+
// a parent to this boundary was displayed. However, since we currently don't have
1692+
// a protocol to transfer that time, we'll just estimate it by using the current
1693+
// time. This will mean that Suspense timeouts are slightly shifted to later than
1694+
// they should be.
1695+
let serverDisplayTime = requestCurrentTime();
1696+
// Schedule a normal pri update to render this content.
1697+
workInProgress.expirationTime = computeAsyncExpiration(serverDisplayTime);
1698+
} else {
1699+
// We'll continue hydrating the rest at offscreen priority since we'll already
1700+
// be showing the right content coming from the server, it is no rush.
1701+
workInProgress.expirationTime = Never;
1702+
}
16431703
return null;
16441704
}
16451705
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
@@ -1648,55 +1708,31 @@ function updateDehydratedSuspenseComponent(
16481708
workInProgress.child = null;
16491709
return null;
16501710
}
1711+
if (isSuspenseInstanceFallback(suspenseInstance)) {
1712+
// This boundary is in a permanent fallback state. In this case, we'll never
1713+
// get an update and we'll never be able to hydrate the final content. Let's just try the
1714+
// client side render instead.
1715+
return retrySuspenseComponentWithoutHydrating(
1716+
current,
1717+
workInProgress,
1718+
renderExpirationTime,
1719+
);
1720+
}
16511721
// We use childExpirationTime to indicate that a child might depend on context, so if
16521722
// any context has changed, we need to treat is as if the input might have changed.
16531723
const hasContextChanged = current.childExpirationTime >= renderExpirationTime;
1654-
const suspenseInstance = (current.stateNode: SuspenseInstance);
1655-
if (
1656-
didReceiveUpdate ||
1657-
hasContextChanged ||
1658-
isSuspenseInstanceFallback(suspenseInstance)
1659-
) {
1724+
if (didReceiveUpdate || hasContextChanged) {
16601725
// This boundary has changed since the first render. This means that we are now unable to
16611726
// hydrate it. We might still be able to hydrate it using an earlier expiration time but
16621727
// during this render we can't. Instead, we're going to delete the whole subtree and
16631728
// instead inject a new real Suspense boundary to take its place, which may render content
16641729
// or fallback. The real Suspense boundary will suspend for a while so we have some time
16651730
// to ensure it can produce real content, but all state and pending events will be lost.
1666-
1667-
// Alternatively, this boundary is in a permanent fallback state. In this case, we'll never
1668-
// get an update and we'll never be able to hydrate the final content. Let's just try the
1669-
// client side render instead.
1670-
1671-
// Detach from the current dehydrated boundary.
1672-
current.alternate = null;
1673-
workInProgress.alternate = null;
1674-
1675-
// Insert a deletion in the effect list.
1676-
let returnFiber = workInProgress.return;
1677-
invariant(
1678-
returnFiber !== null,
1679-
'Suspense boundaries are never on the root. ' +
1680-
'This is probably a bug in React.',
1731+
return retrySuspenseComponentWithoutHydrating(
1732+
current,
1733+
workInProgress,
1734+
renderExpirationTime,
16811735
);
1682-
const last = returnFiber.lastEffect;
1683-
if (last !== null) {
1684-
last.nextEffect = current;
1685-
returnFiber.lastEffect = current;
1686-
} else {
1687-
returnFiber.firstEffect = returnFiber.lastEffect = current;
1688-
}
1689-
current.nextEffect = null;
1690-
current.effectTag = Deletion;
1691-
1692-
// Upgrade this work in progress to a real Suspense component.
1693-
workInProgress.tag = SuspenseComponent;
1694-
workInProgress.stateNode = null;
1695-
workInProgress.memoizedState = null;
1696-
// This is now an insertion.
1697-
workInProgress.effectTag |= Placement;
1698-
// Retry as a real Suspense component.
1699-
return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
17001736
} else if (isSuspenseInstancePending(suspenseInstance)) {
17011737
// This component is still pending more data from the server, so we can't hydrate its
17021738
// content. We treat it as if this component suspended itself. It might seem as if

0 commit comments

Comments
 (0)