Skip to content

Commit ad67ba8

Browse files
committed
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.
1 parent a148cfc commit ad67ba8

File tree

2 files changed

+93
-20
lines changed

2 files changed

+93
-20
lines changed

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,65 @@ 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+
706765
it('waits for pending content to come in from the server and then hydrates it', async () => {
707766
let suspend = false;
708767
let promise = new Promise(resolvePromise => {});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,12 +1632,28 @@ function updateSuspenseComponent(
16321632
}
16331633

16341634
function retrySuspenseComponentWithoutHydrating(
1635-
current: Fiber,
1635+
current: Fiber | null,
16361636
workInProgress: Fiber,
16371637
renderExpirationTime: ExpirationTime,
16381638
) {
1639+
let fiberToDelete = current;
1640+
if (fiberToDelete === null) {
1641+
// We're going to delete the dehydrated boundary but we don't have a
1642+
// current. To be able to insert something in the deletion list we
1643+
// to create one. That's because our workInProgress is going to be
1644+
// upgraded so we can't use that one.
1645+
fiberToDelete = createWorkInProgress(
1646+
workInProgress,
1647+
workInProgress.pendingProps,
1648+
renderExpirationTime,
1649+
);
1650+
fiberToDelete.return = workInProgress.return;
1651+
} else {
1652+
// This is now an insertion.
1653+
workInProgress.effectTag |= Placement;
1654+
}
16391655
// Detach from the current dehydrated boundary.
1640-
current.alternate = null;
1656+
fiberToDelete.alternate = null;
16411657
workInProgress.alternate = null;
16421658

16431659
// Insert a deletion in the effect list.
@@ -1649,20 +1665,18 @@ function retrySuspenseComponentWithoutHydrating(
16491665
);
16501666
const last = returnFiber.lastEffect;
16511667
if (last !== null) {
1652-
last.nextEffect = current;
1653-
returnFiber.lastEffect = current;
1668+
last.nextEffect = fiberToDelete;
1669+
returnFiber.lastEffect = fiberToDelete;
16541670
} else {
1655-
returnFiber.firstEffect = returnFiber.lastEffect = current;
1671+
returnFiber.firstEffect = returnFiber.lastEffect = fiberToDelete;
16561672
}
1657-
current.nextEffect = null;
1658-
current.effectTag = Deletion;
1673+
fiberToDelete.nextEffect = null;
1674+
fiberToDelete.effectTag = Deletion;
16591675

16601676
// Upgrade this work in progress to a real Suspense component.
16611677
workInProgress.tag = SuspenseComponent;
16621678
workInProgress.stateNode = null;
16631679
workInProgress.memoizedState = null;
1664-
// This is now an insertion.
1665-
workInProgress.effectTag |= Placement;
16661680
// Retry as a real Suspense component.
16671681
return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
16681682
}
@@ -1672,6 +1686,17 @@ function updateDehydratedSuspenseComponent(
16721686
workInProgress: Fiber,
16731687
renderExpirationTime: ExpirationTime,
16741688
) {
1689+
const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
1690+
if (isSuspenseInstanceFallback(suspenseInstance)) {
1691+
// This boundary is in a permanent fallback state. In this case, we'll never
1692+
// get an update and we'll never be able to hydrate the final content. Let's just try the
1693+
// client side render instead.
1694+
return retrySuspenseComponentWithoutHydrating(
1695+
current,
1696+
workInProgress,
1697+
renderExpirationTime,
1698+
);
1699+
}
16751700
if (current === null) {
16761701
// During the first pass, we'll bail out and not drill into the children.
16771702
// Instead, we'll leave the content in place and try to hydrate it later.
@@ -1684,17 +1709,6 @@ function updateDehydratedSuspenseComponent(
16841709
workInProgress.child = null;
16851710
return null;
16861711
}
1687-
const suspenseInstance = (current.stateNode: SuspenseInstance);
1688-
if (isSuspenseInstanceFallback(suspenseInstance)) {
1689-
// This boundary is in a permanent fallback state. In this case, we'll never
1690-
// get an update and we'll never be able to hydrate the final content. Let's just try the
1691-
// client side render instead.
1692-
return retrySuspenseComponentWithoutHydrating(
1693-
current,
1694-
workInProgress,
1695-
renderExpirationTime,
1696-
);
1697-
}
16981712
// We use childExpirationTime to indicate that a child might depend on context, so if
16991713
// any context has changed, we need to treat is as if the input might have changed.
17001714
const hasContextChanged = current.childExpirationTime >= renderExpirationTime;

0 commit comments

Comments
 (0)