Skip to content

Commit 6d626fb

Browse files
committed
Register a callback to be fired when a boundary changes away from pending
It's now possible to switch from a pending state to either hydrating or replacing the content.
1 parent dea6d03 commit 6d626fb

File tree

6 files changed

+196
-1
lines changed

6 files changed

+196
-1
lines changed

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

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,4 +681,181 @@ describe('ReactDOMServerPartialHydration', () => {
681681
let span = container.getElementsByTagName('span')[0];
682682
expect(ref.current).toBe(span);
683683
});
684+
685+
it('waits for pending content to come in from the server and then hydrates it', async () => {
686+
let suspend = false;
687+
let promise = new Promise(resolvePromise => {});
688+
let ref = React.createRef();
689+
690+
function Child() {
691+
if (suspend) {
692+
throw promise;
693+
} else {
694+
return 'Hello';
695+
}
696+
}
697+
698+
function App() {
699+
return (
700+
<div>
701+
<Suspense fallback="Loading...">
702+
<span ref={ref}>
703+
<Child />
704+
</span>
705+
</Suspense>
706+
</div>
707+
);
708+
}
709+
710+
// We're going to simulate what Fizz will do during streaming rendering.
711+
712+
// First we generate the HTML of the loading state.
713+
suspend = true;
714+
let loadingHTML = ReactDOMServer.renderToString(<App />);
715+
// Then we generate the HTML of the final content.
716+
suspend = false;
717+
let finalHTML = ReactDOMServer.renderToString(<App />);
718+
719+
let container = document.createElement('div');
720+
container.innerHTML = loadingHTML;
721+
722+
let suspenseNode = container.firstChild.firstChild;
723+
expect(suspenseNode.nodeType).toBe(8);
724+
// Put the suspense node in hydration state.
725+
suspenseNode.data = '$?';
726+
727+
// This will simulates new content streaming into the document and
728+
// replacing the fallback with final content.
729+
function streamInContent() {
730+
let temp = document.createElement('div');
731+
temp.innerHTML = finalHTML;
732+
let finalSuspenseNode = temp.firstChild.firstChild;
733+
let fallbackContent = suspenseNode.nextSibling;
734+
let finalContent = finalSuspenseNode.nextSibling;
735+
suspenseNode.parentNode.replaceChild(finalContent, fallbackContent);
736+
suspenseNode.data = '$';
737+
if (suspenseNode._reactRetry) {
738+
suspenseNode._reactRetry();
739+
}
740+
}
741+
742+
// We're still showing a fallback.
743+
expect(container.getElementsByTagName('span').length).toBe(0);
744+
745+
// Attempt to hydrate the content.
746+
suspend = false;
747+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
748+
root.render(<App />);
749+
jest.runAllTimers();
750+
751+
// We're still loading because we're waiting for the server to stream more content.
752+
expect(container.textContent).toBe('Loading...');
753+
754+
// The server now updates the content in place in the fallback.
755+
streamInContent();
756+
757+
// The final HTML is now in place.
758+
expect(container.textContent).toBe('Hello');
759+
let span = container.getElementsByTagName('span')[0];
760+
761+
// But it is not yet hydrated.
762+
expect(ref.current).toBe(null);
763+
764+
jest.runAllTimers();
765+
766+
// Now it's hydrated.
767+
expect(ref.current).toBe(span);
768+
});
769+
770+
it('handles an error on the client if the server ends up erroring', async () => {
771+
let suspend = false;
772+
let promise = new Promise(resolvePromise => {});
773+
let ref = React.createRef();
774+
775+
function Child() {
776+
if (suspend) {
777+
throw promise;
778+
} else {
779+
throw new Error('Error Message');
780+
}
781+
}
782+
783+
class ErrorBoundary extends React.Component {
784+
state = {error: null};
785+
static getDerivedStateFromError(error) {
786+
return {error};
787+
}
788+
render() {
789+
if (this.state.error) {
790+
return <div ref={ref}>{this.state.error.message}</div>;
791+
}
792+
return this.props.children;
793+
}
794+
}
795+
796+
function App() {
797+
return (
798+
<ErrorBoundary>
799+
<div>
800+
<Suspense fallback="Loading...">
801+
<span ref={ref}>
802+
<Child />
803+
</span>
804+
</Suspense>
805+
</div>
806+
</ErrorBoundary>
807+
);
808+
}
809+
810+
// We're going to simulate what Fizz will do during streaming rendering.
811+
812+
// First we generate the HTML of the loading state.
813+
suspend = true;
814+
let loadingHTML = ReactDOMServer.renderToString(<App />);
815+
816+
let container = document.createElement('div');
817+
container.innerHTML = loadingHTML;
818+
819+
let suspenseNode = container.firstChild.firstChild;
820+
expect(suspenseNode.nodeType).toBe(8);
821+
// Put the suspense node in hydration state.
822+
suspenseNode.data = '$?';
823+
824+
// This will simulates the server erroring and putting the fallback
825+
// as the final state.
826+
function streamInError() {
827+
suspenseNode.data = '$!';
828+
if (suspenseNode._reactRetry) {
829+
suspenseNode._reactRetry();
830+
}
831+
}
832+
833+
// We're still showing a fallback.
834+
expect(container.getElementsByTagName('span').length).toBe(0);
835+
836+
// Attempt to hydrate the content.
837+
suspend = false;
838+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
839+
root.render(<App />);
840+
jest.runAllTimers();
841+
842+
// We're still loading because we're waiting for the server to stream more content.
843+
expect(container.textContent).toBe('Loading...');
844+
845+
// The server now updates the content in place in the fallback.
846+
streamInError();
847+
848+
// The server errored, but we still haven't hydrated. We don't know if the
849+
// client will succeed yet, so we still show the loading state.
850+
expect(container.textContent).toBe('Loading...');
851+
expect(ref.current).toBe(null);
852+
853+
jest.runAllTimers();
854+
855+
// Hydrating should've generated an error and replaced the suspense boundary.
856+
expect(container.textContent).toBe('Error Message');
857+
858+
let div = container.getElementsByTagName('div')[0];
859+
expect(ref.current).toBe(div);
860+
});
684861
});

packages/react-dom/src/client/ReactDOMHostConfig.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export type Props = {
5656
export type Container = Element | Document;
5757
export type Instance = Element;
5858
export type TextInstance = Text;
59-
export type SuspenseInstance = Comment;
59+
export type SuspenseInstance = Comment & {_reactRetry?: () => void};
6060
export type HydratableInstance = Instance | TextInstance | SuspenseInstance;
6161
export type PublicInstance = Element | Text;
6262
type HostContextDev = {
@@ -568,6 +568,13 @@ export function isSuspenseInstanceFallback(instance: SuspenseInstance) {
568568
return instance.data === SUSPENSE_FALLBACK_START_DATA;
569569
}
570570

571+
export function registerSuspenseInstanceRetry(
572+
instance: SuspenseInstance,
573+
callback: () => void,
574+
) {
575+
instance._reactRetry = callback;
576+
}
577+
571578
export function getNextHydratableSibling(
572579
instance: HydratableInstance,
573580
): null | HydratableInstance {

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import {
8686
shouldDeprioritizeSubtree,
8787
isSuspenseInstancePending,
8888
isSuspenseInstanceFallback,
89+
registerSuspenseInstanceRetry,
8990
} from './ReactFiberHostConfig';
9091
import type {SuspenseInstance} from './ReactFiberHostConfig';
9192
import {pushHostContext, pushHostContainer} from './ReactFiberHostContext';
@@ -132,6 +133,7 @@ import {
132133
createWorkInProgress,
133134
isSimpleFunctionComponent,
134135
} from './ReactFiber';
136+
import {retryTimedOutBoundary} from './ReactFiberScheduler';
135137

136138
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
137139

@@ -1708,6 +1710,11 @@ function updateDehydratedSuspenseComponent(
17081710
workInProgress.effectTag |= DidCapture;
17091711
// Leave the children in place. I.e. empty.
17101712
workInProgress.child = null;
1713+
// Register a callback to retry this boundary once the server has sent the result.
1714+
registerSuspenseInstanceRetry(
1715+
suspenseInstance,
1716+
retryTimedOutBoundary.bind(null, current),
1717+
);
17111718
return null;
17121719
} else {
17131720
// This is the first attempt.

packages/react-reconciler/src/ReactFiberScheduler.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2592,6 +2592,7 @@ export {
25922592
renderDidSuspend,
25932593
renderDidError,
25942594
pingSuspendedRoot,
2595+
retryTimedOutBoundary,
25952596
resolveRetryThenable,
25962597
markLegacyErrorBoundaryAsFailed,
25972598
isAlreadyFailedLegacyErrorBoundary,

packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export const isSuspenseInstancePending =
111111
$$$hostConfig.isSuspenseInstancePending;
112112
export const isSuspenseInstanceFallback =
113113
$$$hostConfig.isSuspenseInstanceFallback;
114+
export const registerSuspenseInstanceRetry =
115+
$$$hostConfig.registerSuspenseInstanceRetry;
114116
export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling;
115117
export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild;
116118
export const hydrateInstance = $$$hostConfig.hydrateInstance;

packages/shared/HostConfigWithNoHydration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const canHydrateTextInstance = shim;
2929
export const canHydrateSuspenseInstance = shim;
3030
export const isSuspenseInstancePending = shim;
3131
export const isSuspenseInstanceFallback = shim;
32+
export const registerSuspenseInstanceRetry = shim;
3233
export const getNextHydratableSibling = shim;
3334
export const getFirstHydratableChild = shim;
3435
export const hydrateInstance = shim;

0 commit comments

Comments
 (0)