mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Test showing mismatches after suspending force fallbacks to be shown (#28299)
While investigating https://github.com/facebook/react/issues/28285 I found a possible bug in handling Suspense and mismatches. As the tests show, if the first sibling in a boundary suspends, and the second has a mismatch, we will NOT show a fallback. If the first sibling is a mismatch, and the second sibling suspends, we WILL show a fallback. [Here's a stackbliz showing the behavior on Canary](https://stackblitz.com/edit/stackblitz-starters-bh3snf?file=src%2Fstyle.css,public%2Findex.html,src%2Findex.tsx). This breakage was introduced by: https://github.com/facebook/react/pull/26380. Before this PR, we would not show a fallback in either case. That PR makes it so that we don't pre-render siblings of suspended trees, so presumably, whatever detection we had to avoid fallbacks on mismatches, requires knowing there's a mismatch in the tree when we suspend.
This commit is contained in:
@@ -366,6 +366,775 @@ describe('ReactDOMServerPartialHydration', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show a fallback if mismatch is after suspending', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll(['Suspend']);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Unchanged, continue showing server content while suspended.
|
||||
expect(container.innerHTML).toBe(
|
||||
'<!--$-->Hello<div>Component</div><!--/$-->',
|
||||
);
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll([
|
||||
// first pass, mismatches at end
|
||||
'Hello',
|
||||
'Component',
|
||||
'Hello',
|
||||
'Component',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed.
|
||||
expect(container.innerHTML).toBe('Hello<article>Mismatch</article>');
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'section',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show a fallback if mismatch is child of suspended component', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll(['Suspend']);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Unchanged, continue showing server content while suspended.
|
||||
expect(container.innerHTML).toBe(
|
||||
'<!--$--><div><div>Component</div></div><!--/$-->',
|
||||
);
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll([
|
||||
// first pass, mismatches at end
|
||||
'Hello',
|
||||
'Component',
|
||||
'Hello',
|
||||
'Component',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div><article>Mismatch</article></div>',
|
||||
);
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'div',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in div (at **)\n' +
|
||||
' in Child (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not show a fallback if mismatch is parent and first child suspends', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll(['Component', 'Suspend']);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Unchanged, continue showing server content while suspended.
|
||||
expect(container.innerHTML).toBe(
|
||||
'<!--$--><div><div></div><div>Component</div></div><!--/$-->',
|
||||
);
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll([
|
||||
// first pass, mismatches at end
|
||||
'Component',
|
||||
'Hello',
|
||||
'Component',
|
||||
'Hello',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div><div></div><article>Mismatch</article></div>',
|
||||
);
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'div',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in div (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('does show a fallback if mismatch is parent and second child suspends', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll([
|
||||
'Component',
|
||||
'Component',
|
||||
'Suspend',
|
||||
'Fallback',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Client switches to suspense fallback.
|
||||
expect(container.innerHTML).toBe('Loading...');
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll(['Component', 'Hello']);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed
|
||||
expect(container.innerHTML).toBe(
|
||||
'<div><article>Mismatch</article><div></div></div>',
|
||||
);
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'div',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in div (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('does show a fallback if mismatch is in parent element only', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll([
|
||||
'Component',
|
||||
'Component',
|
||||
'Suspend',
|
||||
'Fallback',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Client switches to suspense fallback.
|
||||
expect(container.innerHTML).toBe('Loading...');
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll(['Component', 'Hello']);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed
|
||||
expect(container.innerHTML).toBe('<article><div></div></article>');
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'section',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('does show a fallback if mismatch is before suspending', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll([
|
||||
'Component',
|
||||
'Component',
|
||||
'Suspend',
|
||||
'Fallback',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Client switches to suspense fallback.
|
||||
expect(container.innerHTML).toBe('Loading...');
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll([
|
||||
// first pass, mismatches at end
|
||||
'Component',
|
||||
'Hello',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed
|
||||
expect(container.innerHTML).toBe('<article>Mismatch</article>Hello');
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'section',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('does show a fallback if mismatch is before suspending in a child', async () => {
|
||||
// We can't use the toErrorDev helper here because this is async.
|
||||
const originalConsoleError = console.error;
|
||||
const mockError = jest.fn();
|
||||
console.error = (...args) => {
|
||||
mockError(...args.map(normalizeCodeLocInfo));
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
try {
|
||||
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(error.message);
|
||||
},
|
||||
});
|
||||
await waitForAll([
|
||||
'Component',
|
||||
'Component',
|
||||
'Suspend',
|
||||
'Fallback',
|
||||
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
||||
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// !! Client switches to suspense fallback.
|
||||
expect(container.innerHTML).toBe('Loading...');
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
await waitForAll([
|
||||
// first pass, mismatches at end
|
||||
'Component',
|
||||
'Hello',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Client rendered - suspense comment nodes removed.
|
||||
expect(container.innerHTML).toBe(
|
||||
'<article>Mismatch</article><div>Hello</div>',
|
||||
);
|
||||
if (__DEV__) {
|
||||
const secondToLastCall =
|
||||
mockError.mock.calls[mockError.mock.calls.length - 2];
|
||||
expect(secondToLastCall).toEqual([
|
||||
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
|
||||
'article',
|
||||
'section',
|
||||
'\n' +
|
||||
' in article (at **)\n' +
|
||||
' in Component (at **)\n' +
|
||||
' in Suspense (at **)\n' +
|
||||
' in App (at **)',
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
it('calls the hydration callbacks after hydration or deletion', async () => {
|
||||
let suspend = false;
|
||||
let resolve;
|
||||
|
||||
Reference in New Issue
Block a user