Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4300,4 +4300,136 @@ describe('ReactDOMServerPartialHydration', () => {
root.unmount();
expect(container.innerHTML).toEqual('<!--&--><!--/&-->');
});

it('recovers when an update changes a dehydrated boundary inside a suspended parent boundary', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));

function Sibling() {
if (suspend) {
throw promise;
}
return <span id="sibling">Sibling</span>;
}

function App({showSiblingOnMount}) {
const [showSibling, setShowSibling] = React.useState(false);
React.useEffect(() => {
if (showSiblingOnMount) {
// Not a transition: this update reaches the dehydrated inner
// boundary at default priority, before it has hydrated.
setShowSibling(true);
}
}, [showSiblingOnMount]);
return (
<div>
<Suspense fallback={null}>
{showSibling ? <Sibling /> : null}
<Suspense fallback={null}>
<span id="content">{showSibling ? 'b' : 'a'}</span>
</Suspense>
</Suspense>
</div>
);
}

// Don't suspend on the server.
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App showSiblingOnMount={false} />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('a');

// Hydrate. The first effect mounts a suspending sibling in the outer
// boundary (so the outer boundary shows its fallback and its primary
// content is hidden), and at the same time changes the input of the
// inner boundary, which is still dehydrated.
suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App showSiblingOnMount={true} />);
});

// The sibling's data arrives.
suspend = false;
await act(async () => {
resolve();
await promise;
});

// The outer boundary should reveal both the sibling and the updated
// inner content.
const sibling = container.querySelector('#sibling');
const content = container.querySelector('#content');
expect(sibling).not.toBe(null);
expect(sibling.style.display).not.toBe('none');
expect(content).not.toBe(null);
expect(content.style.display).not.toBe('none');
expect(content.textContent).toBe('b');
});

it('recovers when a transition changes a dehydrated boundary inside a suspended parent boundary', async () => {
// Same as the previous test, except the update is wrapped
// in startTransition.
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));

function Sibling() {
if (suspend) {
throw promise;
}
return <span id="sibling">Sibling</span>;
}

function App({showSiblingOnMount}) {
const [showSibling, setShowSibling] = React.useState(false);
React.useEffect(() => {
if (showSiblingOnMount) {
React.startTransition(() => {
setShowSibling(true);
});
}
}, [showSiblingOnMount]);
return (
<div>
<Suspense fallback={null}>
{showSibling ? <Sibling /> : null}
<Suspense fallback={null}>
<span id="content">{showSibling ? 'b' : 'a'}</span>
</Suspense>
</Suspense>
</div>
);
}

suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App showSiblingOnMount={false} />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('a');

suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App showSiblingOnMount={true} />);
});

suspend = false;
await act(async () => {
resolve();
await promise;
});

const sibling = container.querySelector('#sibling');
const content = container.querySelector('#content');
expect(sibling).not.toBe(null);
expect(sibling.style.display).not.toBe('none');
expect(content).not.toBe(null);
expect(content.style.display).not.toBe('none');
expect(content.textContent).toBe('b');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2976,4 +2976,129 @@ describe('ReactDOMServerPartialHydrationActivity', () => {
'<div>1</div><span>client</span><div>2</div>',
);
});

it('commits new suspending content next to a dehydrated Activity that hides', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));

function Second() {
if (suspend) {
throw promise;
}
return <span id="second">Second</span>;
}

function App({showSecondOnMount}) {
const [active, setActive] = React.useState('first');
React.useEffect(() => {
if (showSecondOnMount) {
// Not a transition: this update reaches the dehydrated Activity at
// default priority, before it has hydrated.
setActive('second');
}
}, [showSecondOnMount]);
return (
<div>
<Suspense fallback={null}>
{active === 'second' ? <Second /> : null}
<Activity mode={active === 'first' ? 'visible' : 'hidden'}>
<span id="first">First</span>
</Activity>
</Suspense>
</div>
);
}

// Don't suspend on the server.
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App showSecondOnMount={false} />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('First');

// Hydrate. The first effect mounts new content (still loading) and hides
// the server-rendered Activity while its subtree is still dehydrated.
suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App showSecondOnMount={true} />);
});

// The data for the new row arrives.
suspend = false;
await act(async () => {
resolve();
await promise;
});

// The new row should be visible and the old row hidden.
const second = container.querySelector('#second');
const first = container.querySelector('#first');
expect(second).not.toBe(null);
expect(second.style.display).not.toBe('none');
expect(first === null || first.style.display === 'none').toBe(true);
});

it('commits new suspending content next to a dehydrated Activity that hides (transition)', async () => {
// Same as the previous test, except the update is wrapped
// in startTransition.
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));

function Second() {
if (suspend) {
throw promise;
}
return <span id="second">Second</span>;
}

function App({showSecondOnMount}) {
const [active, setActive] = React.useState('first');
React.useEffect(() => {
if (showSecondOnMount) {
React.startTransition(() => {
setActive('second');
});
}
}, [showSecondOnMount]);
return (
<div>
<Suspense fallback={null}>
{active === 'second' ? <Second /> : null}
<Activity mode={active === 'first' ? 'visible' : 'hidden'}>
<span id="first">First</span>
</Activity>
</Suspense>
</div>
);
}

suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App showSecondOnMount={false} />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('First');

suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App showSecondOnMount={true} />);
});

suspend = false;
await act(async () => {
resolve();
await promise;
});

const second = container.querySelector('#second');
const first = container.querySelector('#first');
expect(second).not.toBe(null);
expect(second.style.display).not.toBe('none');
expect(first === null || first.style.display === 'none').toBe(true);
});
});
27 changes: 27 additions & 0 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ import {
import {
pushHiddenContext,
reuseHiddenContextOnStack,
isCurrentTreeHidden,
} from './ReactFiberHiddenContext';
import {findFirstSuspended} from './ReactFiberSuspenseComponent';
import {
Expand Down Expand Up @@ -1019,6 +1020,19 @@ function updateDehydratedActivityComponent(
if (didReceiveUpdate || hasContextChanged) {
// This boundary has changed since the first render. This means that we are now unable to
// hydrate it. We might still be able to hydrate it using a higher priority lane.
if (isCurrentTreeHidden()) {
// This boundary is inside a hidden subtree, where all work is
// deferred until the tree is revealed. Selective hydration works by
// rendering the boundary at a higher priority before the update
// applies, so it can't make progress here; delaying the commit to
// wait for it would deadlock. Replacing hidden content isn't
// visible, so give up and client render.
return retryActivityComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
);
}
const root = getWorkInProgressRoot();
if (root !== null) {
const attemptHydrationAtLane = getBumpedLaneForHydration(
Expand Down Expand Up @@ -3028,6 +3042,19 @@ function updateDehydratedSuspenseComponent(
if (didReceiveUpdate || hasContextChanged) {
// This boundary has changed since the first render. This means that we are now unable to
// hydrate it. We might still be able to hydrate it using a higher priority lane.
if (isCurrentTreeHidden()) {
// This boundary is inside a hidden subtree, where all work is
// deferred until the tree is revealed. Selective hydration works by
// rendering the boundary at a higher priority before the update
// applies, so it can't make progress here; delaying the commit to
// wait for it would deadlock. Replacing hidden content isn't
// visible, so give up and client render.
return retrySuspenseComponentWithoutHydrating(
current,
workInProgress,
renderLanes,
);
}
const root = getWorkInProgressRoot();
if (root !== null) {
const attemptHydrationAtLane = getBumpedLaneForHydration(
Expand Down
Loading