Skip to content

Commit f6dc006

Browse files
committed
Delete server rendered content when props change before hydration completes
1 parent 1476d1e commit f6dc006

File tree

2 files changed

+176
-6
lines changed

2 files changed

+176
-6
lines changed

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,139 @@ describe('ReactDOMServerPartialHydration', () => {
190190

191191
expect(container.firstChild.children[1].textContent).toBe('After');
192192
});
193+
194+
it('regenerates the content if props have changed before hydration completes', async () => {
195+
let suspend = false;
196+
let resolve;
197+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
198+
let ref = React.createRef();
199+
200+
function Child({text}) {
201+
if (suspend) {
202+
throw promise;
203+
} else {
204+
return text;
205+
}
206+
}
207+
208+
function App({text, className}) {
209+
return (
210+
<div>
211+
<Suspense fallback="Loading...">
212+
<span ref={ref} className={className}>
213+
<Child text={text} />
214+
</span>
215+
</Suspense>
216+
</div>
217+
);
218+
}
219+
220+
suspend = false;
221+
let finalHTML = ReactDOMServer.renderToString(
222+
<App text="Hello" className="hello" />,
223+
);
224+
let container = document.createElement('div');
225+
container.innerHTML = finalHTML;
226+
227+
let span = container.getElementsByTagName('span')[0];
228+
229+
// On the client we don't have all data yet but we want to start
230+
// hydrating anyway.
231+
suspend = true;
232+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
233+
root.render(<App text="Hello" className="hello" />);
234+
jest.runAllTimers();
235+
236+
expect(ref.current).toBe(null);
237+
expect(span.textContent).toBe('Hello');
238+
239+
// Render an update, which will be higher or the same priority as pinging the hydration.
240+
root.render(<App text="Hi" className="hi" />);
241+
242+
// At the same time, resolving the promise so that rendering can complete.
243+
suspend = false;
244+
resolve();
245+
await promise;
246+
247+
// Flushing both of these in the same batch won't be able to hydrate so we'll
248+
// probably throw away the existing subtree.
249+
jest.runAllTimers();
250+
251+
// Pick up the new span. In an ideal implementation this might be the same span
252+
// but patched up. At the time of writing, this will be a new span though.
253+
span = container.getElementsByTagName('span')[0];
254+
255+
// We should now have fully rendered with a ref on the new span.
256+
expect(ref.current).toBe(span);
257+
expect(span.textContent).toBe('Hi');
258+
// If we ended up hydrating the existing content, we won't have properly
259+
// patched up the tree, which might mean we haven't patched the className.
260+
expect(span.className).toBe('hi');
261+
});
262+
263+
it('shows the fallback if props have changed before hydration completes and is still suspended', async () => {
264+
let suspend = false;
265+
let resolve;
266+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
267+
let ref = React.createRef();
268+
269+
function Child({text}) {
270+
if (suspend) {
271+
throw promise;
272+
} else {
273+
return text;
274+
}
275+
}
276+
277+
function App({text, className}) {
278+
return (
279+
<div>
280+
<Suspense fallback="Loading...">
281+
<span ref={ref} className={className}>
282+
<Child text={text} />
283+
</span>
284+
</Suspense>
285+
</div>
286+
);
287+
}
288+
289+
suspend = false;
290+
let finalHTML = ReactDOMServer.renderToString(
291+
<App text="Hello" className="hello" />,
292+
);
293+
let container = document.createElement('div');
294+
container.innerHTML = finalHTML;
295+
296+
// On the client we don't have all data yet but we want to start
297+
// hydrating anyway.
298+
suspend = true;
299+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
300+
root.render(<App text="Hello" className="hello" />);
301+
jest.runAllTimers();
302+
303+
expect(ref.current).toBe(null);
304+
305+
// Render an update, but leave it still suspended.
306+
root.render(<App text="Hi" className="hi" />);
307+
308+
// Flushing now should delete the existing content and show the fallback.
309+
jest.runAllTimers();
310+
311+
expect(container.getElementsByTagName('span').length).toBe(0);
312+
expect(ref.current).toBe(null);
313+
expect(container.textContent).toBe('Loading...');
314+
315+
// Unsuspending shows the content.
316+
suspend = false;
317+
resolve();
318+
await promise;
319+
320+
jest.runAllTimers();
321+
322+
let span = container.getElementsByTagName('span')[0];
323+
expect(span.textContent).toBe('Hi');
324+
expect(span.className).toBe('hi');
325+
expect(ref.current).toBe(span);
326+
expect(container.textContent).toBe('Hi');
327+
});
193328
});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
DidCapture,
4545
Update,
4646
Ref,
47+
Deletion,
4748
} from 'shared/ReactSideEffectTags';
4849
import ReactSharedInternals from 'shared/ReactSharedInternals';
4950
import {
@@ -1622,14 +1623,48 @@ function updateDehydratedSuspenseComponent(
16221623
workInProgress.expirationTime = workInProgress.childExpirationTime = Never;
16231624
return null;
16241625
}
1626+
const prevProps = current.memoizedProps;
1627+
const nextProps = workInProgress.pendingProps;
1628+
if (prevProps !== nextProps) {
1629+
// This boundary has changed since the first render. This means that we are now unable to
1630+
// hydrate it. We might still be able to hydrate it using an earlier expiration time but
1631+
// during this render we can't. Instead, we're going to delete the whole subtree and
1632+
// instead inject a new real Suspense boundary to take its place, which may render content
1633+
// or fallback. The real Suspense boundary will suspend for a while so we have some time
1634+
// to ensure it can produce real content, but all state and pending events will be lost.
1635+
1636+
// Detach from the current dehydrated boundary.
1637+
current.alternate = null;
1638+
workInProgress.alternate = null;
1639+
1640+
// Insert a deletion in the effect list.
1641+
let returnFiber = workInProgress.return;
1642+
invariant(
1643+
returnFiber !== null,
1644+
'Suspense boundaries are never on the root. ' +
1645+
'This is probably a bug in React.',
1646+
);
1647+
const last = returnFiber.lastEffect;
1648+
if (last !== null) {
1649+
last.nextEffect = current;
1650+
returnFiber.lastEffect = current;
1651+
} else {
1652+
returnFiber.firstEffect = returnFiber.lastEffect = current;
1653+
}
1654+
current.nextEffect = null;
1655+
current.effectTag = Deletion;
1656+
1657+
// Upgrade this work in progress to a real Suspense component.
1658+
workInProgress.tag = SuspenseComponent;
1659+
workInProgress.stateNode = null;
1660+
workInProgress.memoizedState = null;
1661+
// This is now an insertion.
1662+
workInProgress.effectTag |= Placement;
1663+
// Retry as a real Suspense component.
1664+
return updateSuspenseComponent(null, workInProgress, renderExpirationTime);
1665+
}
16251666
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
16261667
// This is the first attempt.
1627-
const prevProps = current.memoizedProps;
1628-
const nextProps = workInProgress.pendingProps;
1629-
if (prevProps !== nextProps) {
1630-
// TODO: Delete children and upgrade to a regular suspense component without
1631-
// hydrating.
1632-
}
16331668
reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress);
16341669
const nextChildren = nextProps.children;
16351670
workInProgress.child = mountChildFibers(

0 commit comments

Comments
 (0)