Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Avoid accumulating hydration mismatch errors after the first hydration error #24427

Closed
wants to merge 13 commits into from
299 changes: 294 additions & 5 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Expand Up @@ -1207,6 +1207,9 @@ describe('ReactDOMFizzServer', () => {
});

function normalizeCodeLocInfo(str) {
if (typeof str === 'object') {
return;
}
return (
str &&
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
Expand Down Expand Up @@ -2842,8 +2845,84 @@ describe('ReactDOMFizzServer', () => {
});
});

// @gate experimental
it('#24384: Suspending should halt hydration warnings and not emit any if hydration completes successfully after unsuspending', async () => {
const makeApp = () => {
let resolve, resolved;
const promise = new Promise(r => {
resolve = () => {
resolved = true;
return r();
};
});
function ComponentThatSuspends() {
if (!resolved) {
throw promise;
}
return <p>A</p>;
}

const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ComponentThatSuspends />
<h2 name="hello">world</h2>
</Suspense>
</div>
);
};

return [App, resolve];
};

const [ServerApp, serverResolve] = makeApp();
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<ServerApp />);
pipe(writable);
});
await act(() => {
serverResolve();
});

expect(getVisibleChildren(container)).toEqual(
<div>
<p>A</p>
<h2 name="hello">world</h2>
</div>,
);

const [ClientApp, clientResolve] = makeApp();
ReactDOMClient.hydrateRoot(container, <ClientApp />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Logged recoverable error: ' + error.message,
);
},
});
Scheduler.unstable_flushAll();

expect(getVisibleChildren(container)).toEqual(
<div>
<p>A</p>
<h2 name="hello">world</h2>
</div>,
);

// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
// client-side rendering.
await clientResolve();
expect(Scheduler).toFlushWithoutYielding();
expect(getVisibleChildren(container)).toEqual(
<div>
<p>A</p>
<h2 name="hello">world</h2>
</div>,
);
});

// @gate experimental && enableClientRenderFallbackOnTextMismatch
it('#24384: Suspending should halt hydration warnings while still allowing siblings to warm up', async () => {
it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => {
const makeApp = () => {
let resolve, resolved;
const promise = new Promise(r => {
Expand Down Expand Up @@ -2976,8 +3055,6 @@ describe('ReactDOMFizzServer', () => {
},
});
expect(Scheduler).toFlushAndYield([
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: Text content does not match server-rendered HTML.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);
Expand Down Expand Up @@ -3069,8 +3146,6 @@ describe('ReactDOMFizzServer', () => {
});
expect(Scheduler).toFlushAndYield([
'Logged recoverable error: uh oh',
'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);

Expand All @@ -3084,4 +3159,218 @@ describe('ReactDOMFizzServer', () => {

expect(Scheduler).toFlushAndYield([]);
});

// @gate experimental && __DEV__
it('does not invokeGuardedCallback for errors after the first hydration error', async () => {
// We can't use the toErrorDev helper here because this is async.
const originalConsoleError = console.error;
const mockError = jest.fn();
console.error = (...args) => {
if (args.length > 1) {
if (typeof args[1] === 'object') {
mockError(args[0].split('\n')[0]);
return;
}
}
mockError(...args.map(normalizeCodeLocInfo));
};
let isClient = false;
let shouldThrow = true;

function ThrowUntilOnClient({children, message}) {
if (isClient && shouldThrow) {
throw new Error(message);
}
return children;
}

function StopThrowingOnClient() {
if (isClient) {
shouldThrow = false;
}
return null;
}

const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ThrowUntilOnClient message="first error">
<h1>one</h1>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="second error">
<h2>two</h2>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="third error">
<h3>three</h3>
</ThrowUntilOnClient>
<StopThrowingOnClient />
</Suspense>
</div>
);
};

try {
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});

expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
</div>,
);

isClient = true;

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Logged recoverable error: ' + error.message,
);
},
});
expect(Scheduler).toFlushAndYield([
'Logged recoverable error: first error',
'Logged recoverable error: second error',
'Logged recoverable error: third error',
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
]);

expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
</div>,
);

expect(Scheduler).toFlushAndYield([]);

// These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
// when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
expect(mockError.mock.calls).toEqual([
// we only get one because we suppress invokeGuardedCallback after the first one when hydrating in a
// suspense boundary
['Error: Uncaught [Error: first error]'],
]);
} finally {
console.error = originalConsoleError;
}
});

// @gate experimental
it('does not invokeGuardedCallback for errors after a preceding fiber 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) => {
if (args.length > 1) {
if (typeof args[1] === 'object') {
mockError(args[0].split('\n')[0]);
return;
}
}
mockError(...args.map(normalizeCodeLocInfo));
};
let isClient = false;
let shouldThrow = true;
let promise = null;
let unsuspend = null;
let isResolved = false;

function ComponentThatSuspendsOnClient() {
if (isClient && !isResolved) {
if (promise === null) {
promise = new Promise(resolve => {
unsuspend = () => {
isResolved = true;
resolve();
};
});
}
throw promise;
}
return null;
}

function ThrowUntilOnClient({children, message}) {
if (isClient && shouldThrow) {
throw new Error(message);
}
return children;
}

function StopThrowingOnClient() {
if (isClient) {
shouldThrow = false;
}
return null;
}

const App = () => {
return (
<div>
<Suspense fallback={<h1>Loading...</h1>}>
<ComponentThatSuspendsOnClient />
<ThrowUntilOnClient message="first error">
<h1>one</h1>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="second error">
<h2>two</h2>
</ThrowUntilOnClient>
<ThrowUntilOnClient message="third error">
<h3>three</h3>
</ThrowUntilOnClient>
<StopThrowingOnClient />
</Suspense>
</div>
);
};

try {
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});

expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
</div>,
);

isClient = true;

ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
Scheduler.unstable_yieldValue(
'Logged recoverable error: ' + error.message,
);
},
});
expect(Scheduler).toFlushAndYield([]);

expect(getVisibleChildren(container)).toEqual(
<div>
<h1>one</h1>
<h2>two</h2>
<h3>three</h3>
</div>,
);
await unsuspend();
expect(Scheduler).toFlushAndYield([]);

// These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
// when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
expect(mockError.mock.calls).toEqual([]);
} finally {
console.error = originalConsoleError;
}
});
});
Expand Up @@ -350,9 +350,7 @@ describe('ReactDOMServerPartialHydration', () => {
);

if (__DEV__) {
const secondToLastCall =
mockError.mock.calls[mockError.mock.calls.length - 2];
expect(secondToLastCall).toEqual([
expect(mockError.mock.calls[0]).toEqual([
'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
'article',
'section',
Expand Down