Skip to content

Add a way to hydrate and render a Preact app in a defined region of the document head #3285

Open
@jaydenseric

Description

@jaydenseric

Describe the feature you'd love to see

A way to hydrate and render a Preact app in a defined region of the document head.

Additional context (optional)

Using the entire document.head as the Preact app root is not viable, as often analytic scripts, etc. insert themselves or modify the contents of the document head and this would corrupt the Preact hydration and rendering. Also, an isomorphic / SSR web app framework should be able to offer users a way to statically template some of the head tags, while allowing others to be managed dynamically via component rendering side-effects.

A way to hydrate and render a Preact app in a defined region of the document head would be a game-changer for head tag management, as then you could have a Preact app that hydrates and renders the head tags, and another Preact app that hydrates and renders the body HTML. The two apps can hold the same head manager instance in context, to coordinate head tag state updates in response to body component rendering side-effects.

I have 99% of such a system working, but Preact internals need to be slightly modified in order to get it over the line.

The challenge is of course, that the document head doesn't allow nesting DOM nodes under a container node like you can easily do with <div> in the document body. After trying a lot of ideas, the current strategy is to create a virtual DOM node that acts like a parent node for a real DOM node’s child nodes that are between a start and end DOM node:

// This code is to be published under MIT license once my web app framework is
// released.

/**
 * Creates a virtual DOM node that acts like a parent node for a real DOM node’s
 * child nodes that are between a start and end DOM node. Useful for creating a
 * Preact app root to hydrate and render tags in a region of the document head
 * where a real DOM node can’t be used to group child nodes.
 * @param {Node} startNode Start DOM node.
 * @param {Node} endNode End DOM node.
 */
 function createVirtualNode(startNode, endNode) {
  if (!(startNode instanceof Node)) {
    throw new TypeError("Argument 1 `startNode` must be a DOM node.");
  }

  if (!(endNode instanceof Node)) {
    throw new TypeError("Argument 2 `endNode` must be a DOM node.");
  }

  if (!startNode.parentNode) {
    throw new TypeError("Parent DOM node missing.");
  }

  if (startNode.parentNode !== endNode.parentNode) {
    throw new TypeError("Start and end DOM nodes must have the same parent.");
  }

  return new Proxy(startNode.parentNode, {
    get: function (target, propertyKey) {
      switch (propertyKey) {
        case "firstChild": {
          return startNode.nextSibling !== endNode
            ? startNode.nextSibling
            : null;
        }

        case "childNodes": {
          const children = [];

          let child = startNode;

          while (child.nextSibling && child.nextSibling !== endNode) {
            child = child.nextSibling;
            children.push(child);
          }

          return children;
        }

        case "appendChild": {
          return /** @param {Node} node */ (node) =>
            target.insertBefore(node, endNode);
        }

        case "valueOf": {
          return () => target;
        }

        default: {
          const value = Reflect.get(target, propertyKey, target);
          return typeof value === "function" ? value.bind(target) : value;
        }
      }
    },
  });
}

With HTML like this:

<head>
  <meta name="managed-head-start" />
  <title>Example of what the head manager could render in the head Preact app</title>
  <meta name="managed-head-end" />
  <!-- Analytics scripts, etc. may insert here. -->
</head>

Note that in this example I'm using meta tags for the start and end DOM nodes, but you could use text nodes (e.g. <!-- managed-head-start --> or any other uniquely identifiable DOM nodes.

You can then create a new virtual DOM node to act as the head Preact app root:

const headAppRoot = createVirtualNode(
  document.head.querySelector('[name="managed-head-start"]'),
  document.head.querySelector('[name="managed-head-end"]')
);

And use it to hydrate the head Preact app:

import { hydrate } from "preact";
hydrate(<HeadApp />, /** @type {HTMLHeadElement} */ (headAppRoot));

This problem with this system is that sometimes Preact internally checks if DOM nodes are strictly equal. Here are some locations such checks exist:

While our headAppRoot virtual node is a proxy of the real document.head and should be functionally equal to it, these strict equality checks using !== will result in Preact thinking they are not the same. This manifests in the initial hydration after SSR looking ok, all the head tags are adopted at first render, but any following renders due to state changes etc. result in the managed head tags being duplicated. From that point on, the duplicated head tags render in place from state updates etc. ok, but the original SSR tags permanently remain abandoned above.

To deal with this, DOM node equality checks in Preact could be updated like this:

- nodeA !== nodeB
+ nodeA?.valueOf() !== nodeB?.valueOf()

Using .valueOf() on a real DOM node like document.head is perfectly safe; it just returns itself. The beauty is, this allows proxies of DOM nodes (our virtual node) to expose the underlying read DOM node it proxies for use in strict equality checks (see the case "valueOf" in the createVirtualNode implementation show above).

I have tried creating a custom build of Preact with ?.valueOf() inserted at the three locations I could find where there are strict equality checks of DOM nodes, but it seems I don't understand Preact well enough to find all the places, as my modifications aren't solving the duplication issues on re-render. If anyone can identify what I'm missing, please share! I'm desperate.

I feel like the massive amount of time (weeks) I've been spending on userland solutions working with the current Preact API is way less productive than the Preact team coming up with an official solution.

It would be rad if Preact would either offer an official createVirtualNode function or VirtualNode class that can be used as the app root for hydrate or render, or provide new hydration and render function signatures that accept arguments for start and end DOM nodes to define the app root as the slot between.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions