-
Notifications
You must be signed in to change notification settings - Fork 49.9k
Description
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 artsy/fresnel#260 (comment) 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.
In the same commit, further changes are made, with at least the following leading to another problem (assuming error throwing is disabled).
| } else { |
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!