Search code examples
javascriptreact-hookspreacthydration

Preact isn't replacing dom elements on rehydration?


I'm trying to learn rehydration of dom using preact. For some unknown reason, the render function isn't replacing the original DOM node but rather appending to it.

enter image description here

https://github.com/preactjs/preact/issues/24, The 3rd parameter of render should afford the opportunity to replace:

render(<App />, into, into.lastChild);

https://codesandbox.io/s/beautiful-leavitt-rkwlw?file=/index.html:0-1842

Question: Any ideas on how I can ensure that hydration works as one would expect it to e.g. replace the static counter with the interactive one?

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Test</title>
  </head>
  <body>
    <script>
      window.__STATE__ = { components: {} };
    </script>
    <main>
      <div>
        <script data-cmp-id="1">
          window.__STATE__.components[1] = {
            name: "Counter",
            props: { id: 1 }
          };
        </script>
        <div>HOW MANY LIKES 0</div>
        <button>Increment</button>
      </div>
    </main>
    <script type="module">
      import {
        html,
        useState,
        render
      } from "https://unpkg.com/htm/preact/standalone.module.js";
      let id = 0;

      export const withHydration = Component => props => {
        id += 1;
        return html`
          <${Component} ...${props} />
        `;
      };

      const Counter = () => {
        const [likes, setLikes] = useState(0);
        const handleClick = e => {
          e.preventDefault();
          setLikes(likes + 1);
        };

        return html`
          <div>HOW MANY LIKES ${likes}</div>
          <button onClick=${handleClick}>Increment</button>
        `;
      };

      const componentMap = {
        Counter: withHydration(Counter)
      };

      const $componentMarkers = document.querySelectorAll(`[data-cmp-id]`);

      Array.from($componentMarkers).forEach($marker => {
        debugger;
        const $component = $marker.nextElementSibling;
        const { name, props } = window.__STATE__.components[
          $marker.dataset.cmpId
        ];
        const Component = componentMap[name];

        render(
          html`
            <${Component} ...${props} />
          `,
          $component.parentNode,
          $component
        );
      });
    </script>
  </body>
</html>

All of this is inspired by https://github.com/maoberlehner/eleventy-preact repo.


Solution

  • There are two things going on here, I'll explain each:

    1. The third argument to render() should not be needed here.

    Your Counter component has two elements at the root (<div> and <button>), and passing a single DOM element reference as the third argument to render is going to prevent Preact from using the <button> that exists in the "prerendered" DOM.

    By default, render(vdom, parent) will look at all of the children of parent and figure out which ones should be re-used when attaching to existing DOM. There is only one very specific case where this behavior doesn't work and the third argument is warranted, which is when multiple "render roots" share the same parentNode. In general that's a case best avoided, which is why that third parameter isn't really advertised much in documentation.

    2. `htm/preact/standalone` currently appears to be broken

    I'd come across a similar issue last week, so I knew to check this. For some reason, when we bundled Preact into HTM to create the standalone build, it broke rendering. It's likely a result of overly aggressive minification, and should be fixed soon.

    In the meantime, it's possible (and sometimes better) to use htm + preact + preact/hooks directly from unpkg. The key is to use the fully resolved module URLs, so that unpkg's ?module parameter is converting imports to the same URLs you've used for your manual ones. Here's the correct URLs for your demo:

    import htm from "https://unpkg.com/htm@latest?module";
    import { h, render } from "https://unpkg.com/preact@latest?module";
    import { useState } from "https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module";
    

    With the third render argument removed and those imports swapped out, your demo actually works fine: 👍

    https://codesandbox.io/s/fast-fire-dyzhg?file=/index.html:719-954


    Bonus Round: Hydration

    My head is very much in the hydration space right now, so this is of great interest to me. There's a couple of things I would recommend changing in your approach based on my research:

    1. Use JSON for data instead of script tags

    Inline scripts block rendering and force all stylesheets to be loaded fully prior to executing. This makes them disproportionately expensive and worth avoiding at all costs. Thankfully, the solution is pretty simple: instead of using <script>__STATE__[1]={..}</script> for your component hydration data/callsites, switch to <script type=".."> with a non-JavaScript mimetype. That will make the script non-blocking, and you can quickly and easily parse the data as JSON when you hydrate - far faster than evaluating JS, and you control when it happens. Here's what that looks like:

    <div data-component="Counter">
        <div>HOW MANY LIKES 0</div>
        <button>Increment</button>
        <script type="text/hydration-data">
            {"props":{"id":1}}
        </script>
    </div>
    

    Notice that you can now use the location of that script tag inside your data-component root to associate it with the component without the need for global IDs.

    Here's a fork of the fixed version of your demo, with the above change: https://codesandbox.io/s/quirky-wildflower-29944?file=/index.html:202-409

    I hope you find the updated root/data/render loop to be an improvement.

    2. Use hydrate() to skip diffing

    If you know that your pre-rendered HTML structure exactly matches the initial DOM tree structure your components are going to "boot up" into, hydrate() lets you bypass all diffing, boot up quickly and without touching the DOM. Here's the updated demo with render() swapped out for hydrate() - no functional difference, just it'll have better performance:

    https://codesandbox.io/s/thirsty-black-2uci3?file=/index.html:1692-1709