Skip to content

Rendering all breakpoints on the server and then relying on hydration fixup to prune them is too expensive in 18 #23381

@gurkerl83

Description

@gurkerl83

We use a library called Fresnel to achieve the following

  1. Render markup for all breakpoints on the server and send it down the wire.
  2. 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.
  3. 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.

3f5ff16

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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions