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:
Ricky
2024-03-25 20:33:31 -04:00
committed by GitHub
parent 5a75f9e785
commit d3037405c4
@@ -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;