'use strict';
let Activity;
let React = require('react');
let ReactDOM;
let ReactDOMClient;
let ReactDOMServer;
let ReactFeatureFlags;
let Scheduler;
let Suspense;
let SuspenseList;
let useSyncExternalStore;
let act;
let IdleEventPriority;
let waitForAll;
let waitFor;
let waitForPaint;
let assertLog;
let assertConsoleErrorDev;
function normalizeError(msg) {
const idx = msg.indexOf('.');
if (idx > -1) {
return msg.slice(0, idx + 1);
}
return msg;
}
function dispatchMouseEvent(to, from) {
if (!to) {
to = null;
}
if (!from) {
from = null;
}
if (from) {
const mouseOutEvent = document.createEvent('MouseEvents');
mouseOutEvent.initMouseEvent(
'mouseout',
true,
true,
window,
0,
50,
50,
50,
50,
false,
false,
false,
false,
0,
to,
);
from.dispatchEvent(mouseOutEvent);
}
if (to) {
const mouseOverEvent = document.createEvent('MouseEvents');
mouseOverEvent.initMouseEvent(
'mouseover',
true,
true,
window,
0,
50,
50,
50,
50,
false,
false,
false,
false,
0,
from,
);
to.dispatchEvent(mouseOverEvent);
}
}
class TestAppClass extends React.Component {
render() {
return (
<div>
<>{''}</>
<>{'Hello'}</>
</div>
);
}
}
describe('ReactDOMServerPartialHydration', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableSuspenseCallback = true;
ReactFeatureFlags.enableCreateEventHandleAPI = true;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
act = require('internal-test-utils').act;
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');
Activity = React.unstable_Activity;
Suspense = React.Suspense;
useSyncExternalStore = React.useSyncExternalStore;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
waitForPaint = InternalTestUtils.waitForPaint;
waitFor = InternalTestUtils.waitFor;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
IdleEventPriority = require('react-reconciler/constants').IdleEventPriority;
});
function LegacyHiddenDiv({children, mode}) {
return (
<div hidden={mode === 'hidden'}>
<React.unstable_LegacyHidden
mode={mode === 'hidden' ? 'unstable-defer-without-hiding' : mode}>
{children}
</React.unstable_LegacyHidden>
</div>
);
}
it('hydrates a parent even if a child Suspense boundary is blocked', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
suspend = true;
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
expect(ref.current).toBe(null);
suspend = false;
resolve();
await promise;
await waitForAll([]);
expect(ref.current).toBe(span);
});
it('can hydrate siblings of a suspended component without errors', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
<Suspense fallback="Loading...">
<div>Hello</div>
</Suspense>
</Suspense>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.textContent).toBe('HelloHello');
suspend = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([]);
expect(container.textContent).toBe('HelloHello');
suspend = false;
resolve();
await promise;
await waitForAll([]);
expect(container.textContent).toBe('HelloHello');
});
it('falls back to client rendering boundary on mismatch', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child() {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return 'Hello';
}
}
function Component({shouldMismatch}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return <article>Mismatch</article>;
}
return <div>Component</div>;
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
<Component />
<Component />
<Component />
<Component shouldMismatch={true} />
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Hello', 'Component', 'Component', 'Component', 'Component']);
expect(container.innerHTML).toBe(
'<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll(['Suspend']);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
);
suspend = false;
resolve();
await promise;
await waitForAll([
'Hello',
'Component',
'Component',
'Component',
'Component',
'Hello',
'Component',
'Component',
'Component',
'Component',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(container.innerHTML).toBe(
'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
);
});
it('does not show a fallback if mismatch is after suspending', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child() {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return 'Hello';
}
}
function Component({shouldMismatch}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return <article>Mismatch</article>;
}
return <div>Component</div>;
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Child />
<Component shouldMismatch={true} />
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Hello', 'Component']);
expect(container.innerHTML).toBe(
'<!--$-->Hello<div>Component</div><!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll(['Suspend']);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<!--$-->Hello<div>Component</div><!--/$-->',
);
suspend = false;
resolve();
await promise;
await waitForAll([
'Hello',
'Component',
'Hello',
'Component',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('Hello<article>Mismatch</article>');
});
it('does not show a fallback if mismatch is child of suspended component', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child({children}) {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return <div>{children}</div>;
}
}
function Component({shouldMismatch}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return <article>Mismatch</article>;
}
return <div>Component</div>;
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Child>
<Component shouldMismatch={true} />
</Child>
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Hello', 'Component']);
expect(container.innerHTML).toBe(
'<!--$--><div><div>Component</div></div><!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll(['Suspend']);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<!--$--><div><div>Component</div></div><!--/$-->',
);
suspend = false;
resolve();
await promise;
await waitForAll([
'Hello',
'Component',
'Hello',
'Component',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('<div><article>Mismatch</article></div>');
});
it('does not show a fallback if mismatch is parent and first child suspends', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child({children}) {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return <div>{children}</div>;
}
}
function Component({shouldMismatch, children}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return (
<div>
{children}
<article>Mismatch</article>
</div>
);
}
return (
<div>
{children}
<div>Component</div>
</div>
);
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Component shouldMismatch={true}>
<Child />
</Component>
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Component', 'Hello']);
expect(container.innerHTML).toBe(
'<!--$--><div><div></div><div>Component</div></div><!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll(['Component', 'Suspend']);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<!--$--><div><div></div><div>Component</div></div><!--/$-->',
);
suspend = false;
resolve();
await promise;
await waitForAll([
'Component',
'Hello',
'Component',
'Hello',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<div><div></div><article>Mismatch</article></div>',
);
});
it('does show a fallback if mismatch is parent and second child suspends', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child({children}) {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return <div>{children}</div>;
}
}
function Component({shouldMismatch, children}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return (
<div>
<article>Mismatch</article>
{children}
</div>
);
}
return (
<div>
<div>Component</div>
{children}
</div>
);
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Component shouldMismatch={true}>
<Child />
</Component>
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Component', 'Hello']);
expect(container.innerHTML).toBe(
'<!--$--><div><div>Component</div><div></div></div><!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'Component',
'Component',
'Suspend',
'Fallback',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('Loading...');
suspend = false;
resolve();
await promise;
await waitForAll(['Component', 'Hello']);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<div><article>Mismatch</article><div></div></div>',
);
});
it('does show a fallback if mismatch is in parent element only', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child({children}) {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return <div>{children}</div>;
}
}
function Component({shouldMismatch, children}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return <article>{children}</article>;
}
return <div>{children}</div>;
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Component shouldMismatch={true}>
<Child />
</Component>
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Component', 'Hello']);
expect(container.innerHTML).toBe('<!--$--><div><div></div></div><!--/$-->');
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'Component',
'Component',
'Suspend',
'Fallback',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('Loading...');
suspend = false;
resolve();
await promise;
await waitForAll(['Component', 'Hello']);
jest.runAllTimers();
expect(container.innerHTML).toBe('<article><div></div></article>');
});
it('does show a fallback if mismatch is before suspending', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child() {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return 'Hello';
}
}
function Component({shouldMismatch}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return <article>Mismatch</article>;
}
return <div>Component</div>;
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Component shouldMismatch={true} />
<Child />
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Component', 'Hello']);
expect(container.innerHTML).toBe(
'<!--$--><div>Component</div>Hello<!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'Component',
'Component',
'Suspend',
'Fallback',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('Loading...');
suspend = false;
resolve();
await promise;
await waitForAll([
'Component',
'Hello',
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('<article>Mismatch</article>Hello');
});
it('does show a fallback if mismatch is before suspending in a child', async () => {
let client = false;
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child() {
if (suspend) {
Scheduler.log('Suspend');
throw promise;
} else {
Scheduler.log('Hello');
return 'Hello';
}
}
function Component({shouldMismatch}) {
Scheduler.log('Component');
if (shouldMismatch && client) {
return <article>Mismatch</article>;
}
return <div>Component</div>;
}
function Fallback() {
Scheduler.log('Fallback');
return 'Loading...';
}
function App() {
return (
<Suspense fallback={<Fallback />}>
<Component shouldMismatch={true} />
<div>
<Child />
</div>
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('section');
container.innerHTML = finalHTML;
assertLog(['Component', 'Hello']);
expect(container.innerHTML).toBe(
'<!--$--><div>Component</div><div>Hello</div><!--/$-->',
);
suspend = true;
client = true;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([
'Component',
'Component',
'Suspend',
'Fallback',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
jest.runAllTimers();
expect(container.innerHTML).toBe('Loading...');
suspend = false;
resolve();
await promise;
await waitForAll([
'Component',
'Hello',
]);
jest.runAllTimers();
expect(container.innerHTML).toBe(
'<article>Mismatch</article><div>Hello</div>',
);
});
it('calls the hydration callbacks after hydration or deletion', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
let suspend2 = false;
const promise2 = new Promise(() => {});
function Child2() {
if (suspend2) {
throw promise2;
} else {
return 'World';
}
}
function App({value}) {
return (
<div>
<Suspense fallback="Loading...">
<Child />
</Suspense>
<Suspense fallback="Loading...">
<Child2 value={value} />
</Suspense>
</div>
);
}
suspend = false;
suspend2 = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const hydrated = [];
const deleted = [];
suspend = true;
suspend2 = true;
const root = ReactDOMClient.hydrateRoot(container, <App />, {
onHydrated(node) {
hydrated.push(node);
},
onDeleted(node) {
deleted.push(node);
},
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([]);
expect(hydrated.length).toBe(0);
expect(deleted.length).toBe(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(hydrated.length).toBe(1);
expect(deleted.length).toBe(0);
await act(() => {
root.render(<App value={true} />);
});
expect(hydrated.length).toBe(1);
expect(deleted.length).toBe(1);
});
it('hydrates an empty suspense boundary', async () => {
function App() {
return (
<div>
<Suspense fallback="Loading..." />
<div>Sibling</div>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
expect(container.innerHTML).toContain('<div>Sibling</div>');
});
it('recovers with client render when server rendered additional nodes at suspense root', async () => {
function CheckIfHydrating({children}) {
let serverOrClient = '(unknown)';
useSyncExternalStore(
() => {},
() => {
serverOrClient = 'Client rendered';
return null;
},
() => {
serverOrClient = 'Server rendered';
return null;
},
);
Scheduler.log(serverOrClient);
return null;
}
const ref = React.createRef();
function App({hasB}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref}>A</span>
{hasB ? <span>B</span> : null}
<CheckIfHydrating />
</Suspense>
<div>Sibling</div>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
assertLog(['Server rendered']);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).toContain('<span>B</span>');
expect(ref.current).toBe(null);
await act(() => {
ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
});
expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
assertLog([
'Server rendered',
'Client rendered',
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(ref.current).not.toBe(span);
});
it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => {
const ref = React.createRef();
let shouldSuspend = false;
let resolve;
const promise = new Promise(res => {
resolve = () => {
shouldSuspend = false;
res();
};
});
function Suspender() {
if (shouldSuspend) {
throw promise;
}
return <></>;
}
function App({hasB}) {
return (
<div>
<Suspense fallback="Loading...">
<Suspender />
<span ref={ref}>A</span>
{hasB ? <span>B</span> : null}
</Suspense>
<div>Sibling</div>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).toContain('<span>B</span>');
expect(ref.current).toBe(null);
shouldSuspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
});
await act(() => {
resolve();
});
assertLog([
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
expect(ref.current).not.toBe(span);
});
it('recovers with client render when server rendered additional nodes deep inside suspense root', async () => {
const ref = React.createRef();
function App({hasB}) {
return (
<div>
<Suspense fallback="Loading...">
<div>
<span ref={ref}>A</span>
{hasB ? <span>B</span> : null}
</div>
</Suspense>
<div>Sibling</div>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).toContain('<span>B</span>');
expect(ref.current).toBe(null);
await act(() => {
ReactDOMClient.hydrateRoot(container, <App hasB={false} />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
});
assertLog([
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(container.innerHTML).toContain('<span>A</span>');
expect(container.innerHTML).not.toContain('<span>B</span>');
expect(ref.current).not.toBe(span);
});
it('calls the onDeleted hydration callback if the parent gets deleted', async () => {
let suspend = false;
const promise = new Promise(() => {});
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App({deleted}) {
if (deleted) {
return null;
}
return (
<div>
<Suspense fallback="Loading...">
<Child />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const deleted = [];
suspend = true;
const root = await act(() => {
return ReactDOMClient.hydrateRoot(container, <App />, {
onDeleted(node) {
deleted.push(node);
},
});
});
expect(deleted.length).toBe(0);
await act(() => {
root.render(<App deleted={true} />);
});
expect(deleted.length).toBe(1);
});
it('can insert siblings before the dehydrated boundary', async () => {
let suspend = false;
const promise = new Promise(() => {});
let showSibling;
function Child() {
if (suspend) {
throw promise;
} else {
return 'Second';
}
}
function Sibling() {
const [visible, setVisibilty] = React.useState(false);
showSibling = () => setVisibilty(true);
if (visible) {
return <div>First</div>;
}
return null;
}
function App() {
return (
<div>
<Sibling />
<Suspense fallback="Loading...">
<span>
<Child />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(container.firstChild.firstChild.tagName).not.toBe('DIV');
await act(() => showSibling());
expect(container.firstChild.firstChild.tagName).toBe('DIV');
expect(container.firstChild.firstChild.textContent).toBe('First');
});
it('can delete the dehydrated boundary before it is hydrated', async () => {
let suspend = false;
const promise = new Promise(() => {});
let hideMiddle;
function Child() {
if (suspend) {
throw promise;
} else {
return (
<>
<div>Middle</div>
Some text
</>
);
}
}
function App() {
const [visible, setVisibilty] = React.useState(true);
hideMiddle = () => setVisibilty(false);
return (
<div>
<div>Before</div>
{visible ? (
<Suspense fallback="Loading...">
<Child />
</Suspense>
) : null}
<div>After</div>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(container.firstChild.children[1].textContent).toBe('Middle');
await act(() => hideMiddle());
expect(container.firstChild.children[1].textContent).toBe('After');
});
it('blocks updates to hydrate the content first if props have changed', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(span.textContent).toBe('Hello');
root.render(<App text="Hi" className="hi" />);
await act(async () => {
suspend = false;
resolve();
await promise;
});
const newSpan = container.getElementsByTagName('span')[0];
expect(span).toBe(newSpan);
expect(ref.current).toBe(span);
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
});
it('blocks updates to hydrate the content first if props changed at idle priority', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(span.textContent).toBe('Hello');
ReactDOM.unstable_runWithPriority(IdleEventPriority, () => {
root.render(<App text="Hi" className="hi" />);
});
suspend = false;
resolve();
await promise;
await waitForAll([]);
const newSpan = container.getElementsByTagName('span')[0];
expect(span).toBe(newSpan);
expect(ref.current).toBe(span);
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
});
it('shows the fallback if props have changed before hydration completes and is still suspended', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
{
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
await waitForAll([]);
expect(ref.current).toBe(null);
await act(() => {
root.render(<App text="Hi" className="hi" />);
});
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
await act(async () => {
suspend = false;
resolve();
await promise;
});
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
});
it('treats missing fallback the same as if it was defined', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Suspense>
<Child text={text} />
</Suspense>
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
{
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
await waitForAll([]);
const span = container.getElementsByTagName('span')[0];
expect(ref.current).toBe(span);
await act(() => {
root.render(<App text="Hi" className="hi" />);
});
expect(container.getElementsByTagName('span').length).toBe(1);
expect(ref.current).toBe(span);
expect(container.textContent).toBe('');
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
});
it('clears nested suspense boundaries if they did not hydrate yet', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<Suspense fallback="Never happens">
<Child text={text} />
</Suspense>{' '}
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
{
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
await waitForAll([]);
expect(ref.current).toBe(null);
await act(() => {
root.render(<App text="Hi" className="hi" />);
});
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
await act(async () => {
suspend = false;
resolve();
await promise;
});
await waitForAll([]);
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi Hi');
});
it('hydrates first if props changed but we are able to resolve within a timeout', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref} className={className}>
<Child text={text} />
</span>
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<App text="Hello" className="hello" />,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Hello');
React.startTransition(() => root.render(<App text="Hi" className="hi" />));
await waitForAll([]);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Hello');
suspend = false;
await act(() => resolve());
const newSpan = container.getElementsByTagName('span')[0];
expect(span).toBe(newSpan);
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
expect(span.className).toBe('hi');
});
it('warns but works if setState is called before commit in a dehydrated component', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
let updateText;
function Child() {
const [state, setState] = React.useState('Hello');
updateText = setState;
Scheduler.log('Child');
if (suspend) {
throw promise;
} else {
return state;
}
}
function Sibling() {
Scheduler.log('Sibling');
return null;
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Child />
<Sibling />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
assertLog(['Child', 'Sibling']);
const container = document.createElement('div');
container.innerHTML = finalHTML;
ReactDOMClient.hydrateRoot(
container,
<App text="Hello" className="hello" />,
);
await act(async () => {
suspend = true;
await waitFor(['Child']);
updateText('Hi');
assertConsoleErrorDev([
"Can't perform a React state update on a component that hasn't mounted yet. " +
'This indicates that you have a side-effect in your render function that ' +
'asynchronously tries to update the component. Move this work to useEffect instead.\n' +
' in App (at **)',
]);
await waitForAll(['Child']);
expect(container.textContent).toBe('Hello');
suspend = false;
resolve();
await promise;
});
assertLog(['Child', 'Sibling']);
expect(container.textContent).toBe('Hello');
});
it('blocks the update to hydrate first if context has changed', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
const Context = React.createContext(null);
function Child() {
const {text, className} = React.useContext(Context);
if (suspend) {
throw promise;
} else {
return (
<span ref={ref} className={className}>
{text}
</span>
);
}
}
const App = React.memo(function App() {
return (
<div>
<Suspense fallback="Loading...">
<Child />
</Suspense>
</div>
);
});
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(span.textContent).toBe('Hello');
root.render(
<Context.Provider value={{text: 'Hi', className: 'hi'}}>
<App />
</Context.Provider>,
);
await act(async () => {
suspend = false;
resolve();
await promise;
});
const newSpan = container.getElementsByTagName('span')[0];
expect(newSpan).toBe(span);
expect(ref.current).toBe(span);
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
});
it('shows the fallback if context has changed before hydration completes and is still suspended', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
const Context = React.createContext(null);
function Child() {
const {text, className} = React.useContext(Context);
if (suspend) {
throw promise;
} else {
return (
<span ref={ref} className={className}>
{text}
</span>
);
}
}
const App = React.memo(function App() {
return (
<div>
<Suspense fallback="Loading...">
<Child />
</Suspense>
</div>
);
});
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<Context.Provider value={{text: 'Hello', className: 'hello'}}>
<App />
</Context.Provider>,
{
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
await waitForAll([]);
expect(ref.current).toBe(null);
await act(() => {
root.render(
<Context.Provider value={{text: 'Hi', className: 'hi'}}>
<App />
</Context.Provider>,
);
});
expect(container.getElementsByTagName('span').length).toBe(0);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Loading...');
await act(async () => {
suspend = false;
resolve();
await promise;
});
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Hi');
expect(span.className).toBe('hi');
expect(ref.current).toBe(span);
expect(container.textContent).toBe('Hi');
});
it('replaces the fallback with client content if it is not rendered by the server', async () => {
let suspend = false;
const promise = new Promise(resolvePromise => {});
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
</Suspense>
</div>
);
}
suspend = true;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.getElementsByTagName('span').length).toBe(0);
suspend = false;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
if (__DEV__) {
await waitForAll([
'onRecoverableError: Switched to client rendering because the server rendering aborted due to:\n\n' +
'The server used' +
' "renderToString" which does not support Suspense.',
]);
} else {
await waitForAll([
'onRecoverableError: The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering.',
]);
}
jest.runAllTimers();
expect(container.textContent).toBe('Hello');
const span = container.getElementsByTagName('span')[0];
expect(ref.current).toBe(span);
});
it('replaces the fallback within the suspended time if there is a nested suspense', async () => {
let suspend = false;
const promise = new Promise(resolvePromise => {});
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function InnerChild() {
throw promise;
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
<Suspense fallback={null}>
<InnerChild />
</Suspense>
</Suspense>
</div>
);
}
suspend = true;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.getElementsByTagName('span').length).toBe(0);
suspend = false;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
if (__DEV__) {
await waitForAll([
'onRecoverableError: Switched to client rendering because the server rendering aborted due to:\n\n' +
'The server used' +
' "renderToString" which does not support Suspense.',
]);
} else {
await waitForAll([
'onRecoverableError: The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering.',
]);
}
jest.advanceTimersByTime(500);
expect(container.textContent).toBe('Hello');
const span = container.getElementsByTagName('span')[0];
expect(ref.current).toBe(span);
});
it('replaces the fallback within the suspended time if there is a nested suspense in a nested suspense', async () => {
let suspend = false;
const promise = new Promise(resolvePromise => {});
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function InnerChild() {
throw promise;
}
function App() {
return (
<div>
<Suspense fallback="Another layer">
<Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
<Suspense fallback={null}>
<InnerChild />
</Suspense>
</Suspense>
</Suspense>
</div>
);
}
suspend = true;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container.getElementsByTagName('span').length).toBe(0);
suspend = false;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
if (__DEV__) {
await waitForAll([
'onRecoverableError: Switched to client rendering because the server rendering aborted due to:\n\n' +
'The server used' +
' "renderToString" which does not support Suspense.',
]);
} else {
await waitForAll([
'onRecoverableError: The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering.',
]);
}
jest.advanceTimersByTime(500);
expect(container.textContent).toBe('Hello');
const span = container.getElementsByTagName('span')[0];
expect(ref.current).toBe(span);
});
it('shows inserted items in a SuspenseList before content is hydrated', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({children}) {
if (suspend) {
throw promise;
} else {
return children;
}
}
const a = (
<Suspense fallback="Loading A">
<Child>
<span>A</span>
</Child>
</Suspense>
);
const b = (
<Suspense fallback="Loading B">
<Child>
<span ref={ref}>B</span>
</Child>
</Suspense>
);
function App({showMore}) {
return (
<SuspenseList revealOrder="forwards" tail="visible">
{a}
{b}
{showMore ? (
<Suspense fallback="Loading C">
<span>C</span>
</Suspense>
) : null}
</SuspenseList>
);
}
suspend = false;
const html = ReactDOMServer.renderToString(<App showMore={false} />);
const container = document.createElement('div');
container.innerHTML = html;
const spanB = container.getElementsByTagName('span')[1];
suspend = true;
const root = await act(() =>
ReactDOMClient.hydrateRoot(container, <App showMore={false} />),
);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('AB');
await act(() => {
root.render(<App showMore={true} />);
});
expect(ref.current).toBe(null);
expect(container.textContent).toBe('ABC');
suspend = false;
await act(async () => {
await resolve();
});
expect(container.textContent).toBe('ABC');
expect(ref.current).toBe(spanB);
});
it('shows is able to hydrate boundaries even if others in a list are pending', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({children}) {
if (suspend) {
throw promise;
} else {
return children;
}
}
const promise2 = new Promise(() => {});
function AlwaysSuspend() {
throw promise2;
}
const a = (
<Suspense fallback="Loading A">
<Child>
<span ref={ref}>A</span>
</Child>
</Suspense>
);
function App({showMore}) {
return (
<SuspenseList revealOrder="together">
{a}
{showMore ? (
<Suspense fallback="Loading B">
<AlwaysSuspend />
</Suspense>
) : null}
</SuspenseList>
);
}
suspend = false;
const html = ReactDOMServer.renderToString(<App showMore={false} />);
const container = document.createElement('div');
container.innerHTML = html;
const spanA = container.getElementsByTagName('span')[0];
suspend = true;
const root = await act(() =>
ReactDOMClient.hydrateRoot(container, <App showMore={false} />),
);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('A');
await act(async () => {
root.render(<App showMore={true} />);
suspend = false;
await resolve();
});
expect(ref.current).toBe(spanA);
expect(container.textContent).toBe('ALoading B');
});
it('clears server boundaries when SuspenseList runs out of time hydrating', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({children}) {
if (suspend) {
throw promise;
} else {
return children;
}
}
function Before() {
Scheduler.log('Before');
return null;
}
function After() {
Scheduler.log('After');
return null;
}
function FirstRow() {
return (
<>
<Before />
<Suspense fallback="Loading A">
<span>A</span>
</Suspense>
<After />
</>
);
}
function App() {
return (
<Suspense fallback={null}>
<SuspenseList revealOrder="forwards" tail="hidden">
<FirstRow />
<Suspense fallback="Loading B">
<Child>
<span ref={ref}>B</span>
</Child>
</Suspense>
</SuspenseList>
</Suspense>
);
}
suspend = false;
const html = ReactDOMServer.renderToString(<App />);
assertLog(['Before', 'After']);
const container = document.createElement('div');
container.innerHTML = html;
const b = container.getElementsByTagName('span')[1];
expect(b.textContent).toBe('B');
const root = ReactDOMClient.hydrateRoot(container, <App />);
root.unstable_scheduleHydration(b);
suspend = true;
await act(async () => {
await waitFor(['Before', 'After']);
});
expect(ref.current).toBe(null);
expect(container.textContent).toBe('AB');
suspend = false;
await act(async () => {
await resolve();
});
expect(container.textContent).toBe('AB');
expect(ref.current).toBe(b);
});
it('clears server boundaries when SuspenseList suspends last row hydrating', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Child({children}) {
if (suspend) {
throw promise;
} else {
return children;
}
}
function App() {
return (
<Suspense fallback={null}>
<SuspenseList revealOrder="forwards" tail="hidden">
<Suspense fallback="Loading A">
<span>A</span>
</Suspense>
<Suspense fallback="Loading B">
<Child>
<span>B</span>
</Child>
</Suspense>
</SuspenseList>
</Suspense>
);
}
suspend = true;
const html = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = html;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
suspend = true;
if (__DEV__) {
await waitForAll([
'onRecoverableError: Switched to client rendering because the server rendering aborted due to:\n\n' +
'The server used' +
' "renderToString" which does not support Suspense.',
]);
} else {
await waitForAll([
'onRecoverableError: The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering.',
]);
}
expect(container.textContent).toBe('ALoading B');
suspend = false;
await act(async () => {
await resolve();
});
expect(container.textContent).toBe('AB');
});
it('can client render nested boundaries', async () => {
let suspend = false;
const promise = new Promise(() => {});
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
function App() {
return (
<div>
<Suspense
fallback={
<>
<Suspense fallback="Loading...">
<Child />
</Suspense>
<span>Inner Sibling</span>
</>
}>
<Child />
</Suspense>
<span ref={ref}>Sibling</span>
</div>
);
}
suspend = true;
const html = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = html + '<!--unrelated comment-->';
const span = container.getElementsByTagName('span')[1];
suspend = false;
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
if (__DEV__) {
await waitForAll([
'onRecoverableError: Switched to client rendering because the server rendering aborted due to:\n\n' +
'The server used' +
' "renderToString" which does not support Suspense.',
]);
} else {
await waitForAll([
'onRecoverableError: The server could not finish this Suspense boundary, likely due to ' +
'an error during server rendering.',
]);
}
jest.runAllTimers();
expect(ref.current).toBe(span);
expect(span.parentNode).not.toBe(null);
expect(container.lastChild.nodeType).toBe(8);
expect(container.lastChild.data).toBe('unrelated comment');
});
it('can hydrate TWO suspense boundaries', async () => {
const ref1 = React.createRef();
const ref2 = React.createRef();
function App() {
return (
<div>
<Suspense fallback="Loading 1...">
<span ref={ref1}>1</span>
</Suspense>
<Suspense fallback="Loading 2...">
<span ref={ref2}>2</span>
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span1 = container.getElementsByTagName('span')[0];
const span2 = container.getElementsByTagName('span')[1];
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
expect(ref1.current).toBe(span1);
expect(ref2.current).toBe(span2);
});
it('regenerates if it cannot hydrate before changes to props/context expire', async () => {
let suspend = false;
const promise = new Promise(resolvePromise => {});
const ref = React.createRef();
const ClassName = React.createContext(null);
function Child({text}) {
const className = React.useContext(ClassName);
if (suspend && className !== 'hi' && text !== 'Hi') {
throw promise;
} else {
return (
<span ref={ref} className={className}>
{text}
</span>
);
}
}
function App({text, className}) {
return (
<div>
<Suspense fallback="Loading...">
<Child text={text} />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(
<ClassName.Provider value={'hello'}>
<App text="Hello" />
</ClassName.Provider>,
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<ClassName.Provider value={'hello'}>
<App text="Hello" />
</ClassName.Provider>,
{
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(span.textContent).toBe('Hello');
await act(() => {
root.render(
<ClassName.Provider value={'hi'}>
<App text="Hi" />
</ClassName.Provider>,
);
});
const newSpan = container.getElementsByTagName('span')[0];
expect(newSpan).not.toBe(span);
expect(ref.current).toBe(newSpan);
expect(newSpan.textContent).toBe('Hi');
expect(newSpan.className).toBe('hi');
});
it('does not invoke an event on a hydrated node until it commits', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Sibling({text}) {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
let clicks = 0;
function Button() {
const [clicked, setClicked] = React.useState(false);
if (clicked) {
return null;
}
return (
<a
onClick={() => {
setClicked(true);
clicks++;
}}>
Click me
</a>
);
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Button />
<Sibling />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const a = container.getElementsByTagName('a')[0];
suspend = true;
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
expect(container.textContent).toBe('Click meHello');
await act(() => {
a.click();
});
expect(clicks).toBe(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(clicks).toBe(0);
expect(container.textContent).toBe('Click meHello');
document.body.removeChild(container);
});
it('does not invoke an event on a hydrated event handle until it commits', async () => {
const setClick = ReactDOM.unstable_createEventHandle('click');
let suspend = false;
let isServerRendering = true;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Sibling({text}) {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
const onEvent = jest.fn();
function Button() {
const ref = React.useRef(null);
if (!isServerRendering) {
React.useLayoutEffect(() => {
return setClick(ref.current, onEvent);
});
}
return <a ref={ref}>Click me</a>;
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Button />
<Sibling />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const a = container.getElementsByTagName('a')[0];
suspend = true;
isServerRendering = false;
ReactDOMClient.hydrateRoot(container, <App />);
a.click();
expect(onEvent).toHaveBeenCalledTimes(0);
await waitForAll([]);
await act(() => {
a.click();
});
expect(onEvent).toHaveBeenCalledTimes(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(onEvent).toHaveBeenCalledTimes(0);
document.body.removeChild(container);
});
it('invokes discrete events on nested suspense boundaries in a root (legacy system)', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
let clicks = 0;
function Button() {
return (
<a
onClick={() => {
clicks++;
}}>
Click me
</a>
);
}
function Child() {
if (suspend) {
throw promise;
} else {
return (
<Suspense fallback="Loading...">
<Button />
</Suspense>
);
}
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
</Suspense>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const a = container.getElementsByTagName('a')[0];
suspend = true;
ReactDOMClient.hydrateRoot(container, <App />);
await act(() => {
a.click();
});
expect(clicks).toBe(0);
await waitForAll([]);
await act(() => {
a.click();
});
expect(clicks).toBe(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(clicks).toBe(0);
document.body.removeChild(container);
});
it('invokes discrete events on nested suspense boundaries in a root (createEventHandle)', async () => {
let suspend = false;
let isServerRendering = true;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const onEvent = jest.fn();
const setClick = ReactDOM.unstable_createEventHandle('click');
function Button() {
const ref = React.useRef(null);
if (!isServerRendering) {
React.useLayoutEffect(() => {
return setClick(ref.current, onEvent);
});
}
return <a ref={ref}>Click me</a>;
}
function Child() {
if (suspend) {
throw promise;
} else {
return (
<Suspense fallback="Loading...">
<Button />
</Suspense>
);
}
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
</Suspense>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const a = container.getElementsByTagName('a')[0];
suspend = true;
isServerRendering = false;
ReactDOMClient.hydrateRoot(container, <App />);
a.click();
expect(onEvent).toHaveBeenCalledTimes(0);
await waitForAll([]);
await act(() => {
a.click();
});
expect(onEvent).toHaveBeenCalledTimes(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(onEvent).toHaveBeenCalledTimes(0);
document.body.removeChild(container);
});
it('does not invoke the parent of dehydrated boundary event', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
let clicksOnParent = 0;
let clicksOnChild = 0;
function Child({text}) {
if (suspend) {
throw promise;
} else {
return (
<span
onClick={e => {
// The stopPropagation is showing an example why invoking
// the event on only a parent might not be correct.
e.stopPropagation();
clicksOnChild++;
}}>
Hello
</span>
);
}
}
function App() {
return (
<div onClick={() => clicksOnParent++}>
<Suspense fallback="Loading...">
<Child />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const span = container.getElementsByTagName('span')[0];
suspend = true;
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
await act(() => {
span.click();
});
expect(clicksOnChild).toBe(0);
expect(clicksOnParent).toBe(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(clicksOnChild).toBe(0);
expect(clicksOnParent).toBe(0);
document.body.removeChild(container);
});
it('does not invoke an event on a parent tree when a subtree is dehydrated', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
let clicks = 0;
const childSlotRef = React.createRef();
function Parent() {
return <div onClick={() => clicks++} ref={childSlotRef} />;
}
function Child({text}) {
if (suspend) {
throw promise;
} else {
return <a>Click me</a>;
}
}
function App() {
return (
<Suspense fallback="Loading...">
<Child />
</Suspense>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const parentContainer = document.createElement('div');
const childContainer = document.createElement('div');
document.body.appendChild(parentContainer);
const parentRoot = ReactDOMClient.createRoot(parentContainer);
await act(() => parentRoot.render(<Parent />));
childSlotRef.current.appendChild(childContainer);
childContainer.innerHTML = finalHTML;
const a = childContainer.getElementsByTagName('a')[0];
suspend = true;
await act(() => ReactDOMClient.hydrateRoot(childContainer, <App />));
await act(() => {
a.click();
});
expect(clicks).toBe(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(clicks).toBe(0);
document.body.removeChild(parentContainer);
});
it('blocks only on the last continuous event (legacy system)', async () => {
let suspend1 = false;
let resolve1;
const promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise));
let suspend2 = false;
let resolve2;
const promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise));
function First({text}) {
if (suspend1) {
throw promise1;
} else {
return 'Hello';
}
}
function Second({text}) {
if (suspend2) {
throw promise2;
} else {
return 'World';
}
}
const ops = [];
function App() {
return (
<div>
<Suspense fallback="Loading First...">
<span
onMouseEnter={() => ops.push('Mouse Enter First')}
onMouseLeave={() => ops.push('Mouse Leave First')}
/>
{/* We suspend after to test what happens when we eager
attach the listener. */}
<First />
</Suspense>
<Suspense fallback="Loading Second...">
<span
onMouseEnter={() => ops.push('Mouse Enter Second')}
onMouseLeave={() => ops.push('Mouse Leave Second')}>
<Second />
</span>
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const appDiv = container.getElementsByTagName('div')[0];
const firstSpan = appDiv.getElementsByTagName('span')[0];
const secondSpan = appDiv.getElementsByTagName('span')[1];
expect(firstSpan.textContent).toBe('');
expect(secondSpan.textContent).toBe('World');
suspend1 = true;
suspend2 = true;
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
dispatchMouseEvent(appDiv, null);
dispatchMouseEvent(firstSpan, appDiv);
dispatchMouseEvent(secondSpan, firstSpan);
expect(ops).toEqual([]);
suspend2 = false;
resolve2();
await promise2;
await waitForAll([]);
expect(ops).toEqual(['Mouse Enter Second']);
suspend1 = false;
resolve1();
await promise1;
await waitForAll([]);
expect(ops).toEqual(['Mouse Enter Second']);
document.body.removeChild(container);
});
it('finishes normal pri work before continuing to hydrate a retry', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child() {
if (suspend) {
throw promise;
} else {
Scheduler.log('Child');
return 'Hello';
}
}
function Sibling() {
Scheduler.log('Sibling');
React.useLayoutEffect(() => {
Scheduler.log('Commit Sibling');
});
return 'World';
}
const tree = (
<Suspense fallback="Loading...">
<span ref={ref}>
<Child />
</span>
</Suspense>
);
function App({showSibling}) {
return (
<div>
{tree}
{showSibling ? <Sibling /> : null}
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
assertLog(['Child']);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(
container,
<App showSibling={false} />,
);
await waitForAll([]);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('Hello');
suspend = false;
resolve();
await promise;
Scheduler.unstable_advanceTime(100);
root.render(<App showSibling={true} />);
await waitFor(['Sibling', 'Commit Sibling']);
expect(ref.current).toBe(null);
expect(container.textContent).toBe('HelloWorld');
await waitForAll(['Child']);
expect(ref.current).not.toBe(null);
});
it('regression test: does not overfire non-bubbling browser events', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Sibling({text}) {
if (suspend) {
throw promise;
} else {
return 'Hello';
}
}
let submits = 0;
function Form() {
const [submitted, setSubmitted] = React.useState(false);
if (submitted) {
return null;
}
return (
<form
onSubmit={() => {
setSubmitted(true);
submits++;
}}>
Click me
</form>
);
}
function App() {
return (
<div>
<Suspense fallback="Loading...">
<Form />
<Sibling />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
document.body.appendChild(container);
const form = container.getElementsByTagName('form')[0];
suspend = true;
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
expect(container.textContent).toBe('Click meHello');
await act(() => {
form.dispatchEvent(
new window.Event('submit', {
bubbles: true,
}),
);
});
expect(submits).toBe(0);
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(submits).toBe(0);
expect(container.textContent).toBe('Click meHello');
document.body.removeChild(container);
});
it('hydrates a hidden subtree outside of a Suspense boundary', async () => {
const ref = React.createRef();
function App() {
return (
<LegacyHiddenDiv mode="hidden">
<span ref={ref}>Hidden child</span>
</LegacyHiddenDiv>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
expect(span.innerHTML).toBe('Hidden child');
await act(() =>
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + error.message);
},
}),
);
expect(ref.current).toBe(span);
expect(span.innerHTML).toBe('Hidden child');
});
it('renders a hidden LegacyHidden component inside a Suspense boundary', async () => {
const ref = React.createRef();
function App() {
return (
<Suspense fallback="Loading...">
<LegacyHiddenDiv mode="hidden">
<span ref={ref}>Hidden child</span>
</LegacyHiddenDiv>
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
expect(span.innerHTML).toBe('Hidden child');
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
expect(ref.current).toBe(span);
expect(span.innerHTML).toBe('Hidden child');
});
it('renders a visible LegacyHidden component', async () => {
const ref = React.createRef();
function App() {
return (
<LegacyHiddenDiv mode="visible">
<span ref={ref}>Hidden child</span>
</LegacyHiddenDiv>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const span = container.getElementsByTagName('span')[0];
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
expect(ref.current).toBe(span);
expect(ref.current.innerHTML).toBe('Hidden child');
});
it('a visible Activity component is surrounded by comment markers', async () => {
const ref = React.createRef();
function App() {
return (
<Activity mode="visible">
<span ref={ref}>Child</span>
</Activity>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
assertLog([]);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container).toMatchInlineSnapshot(`
<div>
<!--&-->
<span>
Child
</span>
<!--/&-->
</div>
`);
const span = container.getElementsByTagName('span')[0];
ReactDOMClient.hydrateRoot(container, <App />);
await waitForAll([]);
expect(ref.current).toBe(span);
});
it('a hidden Activity component is skipped over during server rendering', async () => {
const visibleRef = React.createRef();
function HiddenChild() {
Scheduler.log('HiddenChild');
return <span>Hidden</span>;
}
function App() {
Scheduler.log('App');
return (
<>
<span ref={visibleRef}>Visible</span>
<Activity mode="hidden">
<HiddenChild />
</Activity>
<Suspense fallback={null}>
<Activity mode="hidden">
<HiddenChild />
</Activity>
</Suspense>
</>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
assertLog(['App']);
const container = document.createElement('div');
container.innerHTML = finalHTML;
expect(container).toMatchInlineSnapshot(`
<div>
<span>
Visible
</span>
<!--$-->
<!--/$-->
</div>
`);
const visibleSpan = container.getElementsByTagName('span')[0];
ReactDOMClient.hydrateRoot(container, <App />);
await waitForPaint(['App']);
expect(visibleRef.current).toBe(visibleSpan);
if (gate(flags => flags.enableYieldingBeforePassive)) {
await waitForPaint([]);
}
await waitForPaint(['HiddenChild']);
expect(container).toMatchInlineSnapshot(`
<div>
<span>
Visible
</span>
<!--$-->
<!--/$-->
<span
style="display: none;"
>
Hidden
</span>
</div>
`);
await waitForPaint(['HiddenChild']);
expect(container).toMatchInlineSnapshot(`
<div>
<span>
Visible
</span>
<!--$-->
<!--/$-->
<span
style="display: none;"
>
Hidden
</span>
<span
style="display: none;"
>
Hidden
</span>
</div>
`);
});
function itHydratesWithoutMismatch(msg, App) {
it('hydrates without mismatch ' + msg, async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const finalHTML = ReactDOMServer.renderToString(<App />);
container.innerHTML = finalHTML;
await act(() => ReactDOMClient.hydrateRoot(container, <App />));
});
}
itHydratesWithoutMismatch('an empty string with neighbors', function App() {
return (
<div>
<div id="test">Test</div>
{'' && <div>Test</div>}
{'Test'}
</div>
);
});
itHydratesWithoutMismatch('an empty string', function App() {
return '';
});
itHydratesWithoutMismatch(
'an empty string simple in fragment',
function App() {
return (
<>
{''}
{'sup'}
</>
);
},
);
itHydratesWithoutMismatch(
'an empty string simple in suspense',
function App() {
return <Suspense>{'' && false}</Suspense>;
},
);
itHydratesWithoutMismatch('an empty string in class component', TestAppClass);
it('fallback to client render on hydration mismatch at root', async () => {
let suspend = true;
let resolve;
const promise = new Promise((res, rej) => {
resolve = () => {
suspend = false;
res();
};
});
function App({isClient}) {
return (
<>
<Suspense fallback={<div>Loading</div>}>
<ChildThatSuspends id={1} isClient={isClient} />
</Suspense>
{isClient ? <span>client</span> : <div>server</div>}
<Suspense fallback={<div>Loading</div>}>
<ChildThatSuspends id={2} isClient={isClient} />
</Suspense>
</>
);
}
function ChildThatSuspends({id, isClient}) {
if (isClient && suspend) {
throw promise;
}
return <div>{id}</div>;
}
const finalHTML = ReactDOMServer.renderToString(<App isClient={false} />);
const container = document.createElement('div');
document.body.appendChild(container);
container.innerHTML = finalHTML;
await act(() => {
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
});
assertLog([
"onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
]);
expect(container.innerHTML).toEqual(
'<div>Loading</div><span>client</span><div>Loading</div>',
);
await act(async () => {
resolve();
await promise;
});
expect(container.innerHTML).toEqual(
'<div>1</div><span>client</span><div>2</div>',
);
});
it("falls back to client rendering when there's a text mismatch (direct text child)", async () => {
function DirectTextChild({text}) {
return <div>{text}</div>;
}
const container = document.createElement('div');
container.innerHTML = ReactDOMServer.renderToString(
<DirectTextChild text="good" />,
);
await act(() => {
ReactDOMClient.hydrateRoot(container, <DirectTextChild text="bad" />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
});
assertLog([
"onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
]);
});
it("falls back to client rendering when there's a text mismatch (text child with siblings)", async () => {
function Sibling() {
return 'Sibling';
}
function TextChildWithSibling({text}) {
return (
<div>
<Sibling />
{text}
</div>
);
}
const container2 = document.createElement('div');
container2.innerHTML = ReactDOMServer.renderToString(
<TextChildWithSibling text="good" />,
);
await act(() => {
ReactDOMClient.hydrateRoot(
container2,
<TextChildWithSibling text="bad" />,
{
onRecoverableError(error) {
Scheduler.log(
'onRecoverableError: ' + normalizeError(error.message),
);
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
},
);
});
assertLog([
"onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
]);
});
it('hides a dehydrated suspense boundary if the parent resuspends', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
const ref = React.createRef();
function Child({text}) {
if (suspend) {
throw promise;
} else {
return text;
}
}
function Sibling({resuspend}) {
if (suspend && resuspend) {
throw promise;
} else {
return null;
}
}
function Component({text}) {
return (
<Suspense>
<Child text={text} />
<span ref={ref}>World</span>
</Suspense>
);
}
function App({text, resuspend}) {
const memoized = React.useMemo(() => <Component text={text} />, [text]);
return (
<div>
<Suspense fallback="Loading...">
{memoized}
<Sibling resuspend={resuspend} />
</Suspense>
</div>
);
}
suspend = false;
const finalHTML = ReactDOMServer.renderToString(<App text="Hello" />);
const container = document.createElement('div');
container.innerHTML = finalHTML;
suspend = true;
const root = ReactDOMClient.hydrateRoot(container, <App text="Hello" />, {
onRecoverableError(error) {
Scheduler.log('onRecoverableError: ' + normalizeError(error.message));
if (error.cause) {
Scheduler.log('Cause: ' + normalizeError(error.cause.message));
}
},
});
await waitForAll([]);
expect(ref.current).toBe(null);
const span = container.getElementsByTagName('span')[0];
const textNode = span.previousSibling;
expect(textNode.nodeValue).toBe('Hello');
expect(span.textContent).toBe('World');
await act(() => {
root.render(<App text="Hello" resuspend={true} />);
});
expect(ref.current).toBe(null);
expect(span.style.display).toBe('none');
expect(textNode.nodeValue).toBe('');
await act(async () => {
suspend = false;
resolve();
await promise;
});
expect(textNode.nodeValue).toBe('Hello');
expect(span.textContent).toBe('World');
expect(span.style.display).toBe('');
expect(ref.current).toBe(span);
});
});