react: Rendering all breakpoints on the server and then relying on hydration fixup to prune them is too expensive in 18
We use a library called Fresnel to achieve the following
- Render markup for all breakpoints on the server and send it down the wire.
- The browser receives markup with proper media query styling and will immediately start rendering the expected visual result for whatever viewport width the browser is at.
- When all JS has loaded and React starts the rehydration phase, we query the browser for what breakpoint it’s currently at and then limit the rendered components to the matching media queries. This prevents life-cycle methods from firing in hidden components and unused html being re-written to the DOM.
Latest compatible version: 18.0.0-rc.0-next-fa816be7f-20220128 First incompatible version: 18.0.0-rc.0-next-3a4462129-20220201 Most recent versions are still incompatible
Identified changes short after the last compatible version was published.
I did some digging into the recent changes in React and may have been able to identify the problem.
The initial report https://github.com/artsy/fresnel/issues/260#issuecomment-1054122815 describes an error thrown when server-side generated components no longer match those on the client-side. This change of application behavior was introduced in the following commit.
https://github.com/facebook/react/commit/3f5ff16c1a743bce3f3d772dc5ac63c5fd2dac0a
In the same commit, further changes are made, with at least the following leading to another problem (assuming error throwing is disabled).
if (nextInstance) {
if (shouldClientRenderOnMismatch(fiber)) {
warnIfUnhydratedTailNodes(fiber);
throwOnHydrationMismatchIfConcurrentMode(fiber); // => (*2)
}
else { // => (*1)
while (nextInstance) {
deleteHydratableInstance(fiber, nextInstance);
nextInstance = getNextHydratableSibling(nextInstance);
}
}
}
Local tests show that the condition statement “if/else” block is wrong; the delete operation must always be executed.
// *1 The delete operation of unmatched siblings needs to be called anyway; otherwise, DOM and React get out of sync, meaning phantom DOM entries (duplicated DOM Elements) get generated when re-rendering occurs. Those elements do not have a corresponding react component in the dev tools.
// *2 Throwing errors have to be optional, not mandatory, options to think about
Remove throwing errors altogether; at least make it optional because the third argument in hydrateRoot is not used/implemented by any consumer of this API, such as in NextJS, although they promise you can use the latest experimental React version Disable enableClientRenderFallbackOnHydrationMismatch when suppressHydrationWarning is set.
Note: When looking at the associated hydration test suite https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js it is noticeable that in the tests mostly suspense is used. In the following test (no test exclusively output after hydration, first and second render run) simple elements are used.
it('with if / else in place', async () => {
function App({hasA, hasB}) {
return (
<div>
<div>{hasA ? <span>A</span> : null}</div>
<div>{hasB ? <span>B</span> : null}</div>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(
<App hasA={true} hasB={true} />, // Render markup for all breakpoints on the server and send it down the wire.
);
const container = document.createElement('div');
container.innerHTML = finalHTML;
const root = ReactDOM.hydrateRoot(
container,
<App hasA={true} hasB={false} />,
);
jest.runAllTimers();
Scheduler.unstable_flushAll();
/**
* Results:
* 1. current version if / else in place (wrong): => <div><div><span>A</span></div><div><span>B</span></div></div>
* 2. only one if - delete on default (expected): <div><div><span>A</span></div><div></div></div>
*/
console.log(
'after hydration / hasA={true} hasB={false}:',
container.innerHTML,
);
root.render(<App hasA={false} hasB={true} />);
jest.runAllTimers();
Scheduler.unstable_flushAll();
/**
* Results:
* 1. current version if / else in place (wrong - phantom elements created): => <div><div></div><div><span>B</span><span>B</span></div></div>
* 2. only one if - delete on default (expected): <div><div></div><div><span>B</span></div></div>
*/
console.log(
'first re-render / hasA={false} hasB={true}:',
container.innerHTML,
);
root.render(<App hasA={true} hasB={false} />);
jest.runAllTimers();
Scheduler.unstable_flushAll();
/**
* Results:
* 1. current version if / else in place (wrong): <div><div><span>A</span></div><div><span>B</span></div></div>
* 2. only one if - delete on default (expected): <div><div><span>A</span></div><div></div></div>
*/
console.log(
'second re-render / hasA={true} hasB={false}:',
container.innerHTML,
);
});
Maybe you guys can give some feedback if the identified problem in those changes made is really the cause to the problem.
Thx!
About this issue
- Original URL
- State: closed
- Created 2 years ago
- Comments: 43 (7 by maintainers)
The supported solutions are:
I understand the frustration here, but the previous solution printed errors in the console. This means it was not supported. We always considered mismatches bugs in the application code that need to be fixed.
There may be some sort of “smart” workarounds possible with 18. For example, wrapping trees in
<Suspense>(which defers their hydration) and making sure you update state to delete the extra variants before they have a chance to hydrate. Then effects from those branches shouldn’t fire. But this is probably not something a library should do since<Suspense>boundaries should be specified by the user. I’m sorry I don’t have a better solution for your use case.In the future, we’d like to add a feature to React that would let you render multiple variants on the server, and have the streaming renderer runtime “pick” the right one on the client. Whether based on media query, localStorage, etc. But this is not something that exists today.
Suspense boundaries let the user specify a fallback UI. A library can’t know what fallback to specify since it’s part of the user’s visual design. A library also can’t make assumptions about the correct granularity of Suspense boundaries. They’re really a visual design concept and need to be intentionally designed. So they belong in the application code.
Let’s keep this open for a bit so we can decide on the recommendation. (I can’t promise the recommendation won’t be that this simply isn’t supported, since it wasn’t an officially supported way before either, but I’d like to have some concrete conclusion for this use case.)
@salazarm Thanks for answering my questions, I will make a reproducer in the form of a minimal NextJs app.