preact icon indicating copy to clipboard operation
preact copied to clipboard

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

Open jaydenseric opened this issue 2 years ago • 8 comments

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:

  • https://github.com/preactjs/preact/blob/bd52611cade159a643b617794410f40a9d52eda3/src/diff/children.js#L195
  • https://github.com/preactjs/preact/blob/bd52611cade159a643b617794410f40a9d52eda3/src/diff/children.js#L303
  • https://github.com/preactjs/preact/blob/bd52611cade159a643b617794410f40a9d52eda3/src/diff/children.js#L306

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.

jaydenseric avatar Oct 20 '21 07:10 jaydenseric

Normally the insertions of analytics scripts should be preserved (if they're deterministic) https://codesandbox.io/s/hardcore-meitner-yzowb?file=/src/index.js:752-773 here we preserve all existing tags but only render in 2 new ones 😅 Will look into what effects valueOf could have

JoviDeCroock avatar Oct 20 '21 07:10 JoviDeCroock

I've had some breakthroughs! I was making some incorrect assumptions that was leading to most of the issues.

The strategy is to hydrate the body app first, so all the declared head content is discovered via useEffect in body app components. Then after, hydrate the head app knowing what the children should be (it should exactly match what SSR created) based off all the head content discovered from hydrating the body app.

I didn't realize (I could swear I experimented for this but mustn't have done so correctly) that after the Preact hydrate function runs, while initial rendering has completed the useEffect hooks for what it rendered haven't run yet. It would be great if the Preact docs for render and hydrate would explain better what has and hasn't happened yet at the time the function has returned.

So what was happening, is I was hydrating the head app before all the head content had been declared to the head manager instance via the body app useEffects. Preact's behavior when it tries to render nothing, but the container has existing DOM nodes, is to just leave them there. In future renders that have content to render, it inserts the DOM nodes after the unexpected ones. That explains what my initial issues were.

So the fix is to put a useEffect in a component wrapping the body app, that calls a callback that triggers the head app to start hydrating. As children useEffects fire before parent ones, this allows us to render the head app after all the body app head content has been declared.

One strange thing though, is that the above fixed approach has a strange bug. None of the head app useEffects run after hydrating the head app. The fixed-fix is to put await Promise.resolve() before calling the Preact hydrate function for the head app - then the head app useEffect hooks function as expected. My guess is that rendering two Preact apps at slightly overlapping times causes the Preact hooks implementation to get confused:

https://github.com/preactjs/preact/blob/bd52611cade159a643b617794410f40a9d52eda3/hooks/src/index.js#L156-L165

My question to the Preact team/community; is it safe to render two separate Preact apps that use hooks in the same browser window at overlapping times? If not, is it a bug, can it be made to be?

jaydenseric avatar Oct 21 '21 23:10 jaydenseric

Also, my original concerns about DOM node equality checks in Preact and the proxy of document.head still needs due diligence. The head tag system I have appears to be working ok now, but I'm kind of surprised that it works with unmodified Preact so well and fear running into a bug at some point. Maybe the app root DOM node doesn't get strict equality checked for the lifetime of the app? That would be great if true, but I don't know Preact internals well enough to confirm. Maybe someone from the Preact team/community can help answer that question!

jaydenseric avatar Oct 21 '21 23:10 jaydenseric

@jaydenseric you can achieve this without the proxy by creating a fake DOM element to pass to Preact's render() or hydrate() methods:

// A fake DOM element we pass to Preact as the render root that exposes/mutates a subsequence of children.
class PersistentFragment {
  constructor(parentNode, childNodes, nextSibling) {
    this.parentNode = parentNode;
    this.childNodes = childNodes;
    this.nextSibling = nextSibling;
  }
  insertBefore(child, before) {
    this.parentNode.insertBefore(child, before || this.nextSibling);
  }
  appendChild(child) {
    this.insertBefore(child);
  }
  removeChild(child) {
    this.parentNode.removeChild(child);
  }
}

// Usage:
const children = [];
const end = document.head.querySelector('[name="managed-head-end"]'); // can be omitted if last!
let node = document.head.querySelector('[name="managed-head-start"]');
while ((node = node.nextElementSibling) && node !== end) children.push(node);
// construct the fake root to hydrate only the given Array of children:
const fakeRoot = new PersistentFragment(document.head, children, end);
hydrate(<HeadStuff />, fakeRoot);

A variant of this that provides subsetted hydrate(vnode, parent, children) and render(vnode, parent, children) can be found here: https://gist.github.com/developit/f321a9ef092ad39f54f8d7c8f99eb29a

Regarding hooks/useEffect: it's safe to run multiple distinct Preact apps on the same page, they will all use the same global scheduler. The bug you ran into is #2798, and your solution is the correct one - invoking render() synchronously within a useEffect() callback resets the global scheduler while it is being flushed. It's a bug, but rather than solve it directly in Preact 10, we're looking to fix it Preact 11 via the createRoot API, which creates scheduler sub-queues for each root.

developit avatar Nov 02 '21 20:11 developit

Also - as an added bonus, PersistentFragment works with <Portal>:

import { createPortal } from 'preact/compat';

const fakeRoot = new PersistentFragment(document.head, children, end); // as above

function App() {
    return (
        <div>
            {createPortal(<HeadStuff />, fakeRoot)}
        </div>
    );
}

render(<App />, document.body);

developit avatar Nov 02 '21 20:11 developit

I've been working hard on this problem again, and am currently stuck due to a Preact rendering bug (https://github.com/preactjs/preact/issues/2783).

The managed head tags are a Fragment array, each with keys. The key for a <link rel="stylesheet" href= for instance contains the href. Ideally, once mounted, the stylesheet link DOM node will be left alone because re-appending it to the DOM causes the browser to refetch the stylesheet, causing dramatic FUOC. Unfortunately, Preact has a bug when rendering an array where a change in the rendered HTML for earlier items causes all the following to be remounted even if their keys and HTML hasn't changed from the previous render.

Here is a demonstration of the unnecessary remounting of DOM nodes and the FUOC it causes:

https://user-images.githubusercontent.com/1754873/145777901-c74210bb-ae4f-46b5-8638-c56bbcdcc59e.mov

Here you can see how just by moving the title related tags to the end of the list of head tags, the redundant re-mounting and FUOC can be avoided:

https://user-images.githubusercontent.com/1754873/145778110-1397d4db-3828-40d7-ad4f-e843a68bd9ff.mov

Trying to workaround the issue by manually ordering things isn't viable, because any of the managed head tags are supposed to be able to change. There is no safe order.

jaydenseric avatar Dec 13 '21 08:12 jaydenseric

For Ruck (the buildless React web application framework for Deno) I ended up having to abandon Preact, due to https://github.com/preactjs/preact/issues/2783#issuecomment-993028422 and also because of types conflicting with React's used by dependencies. Once Preact v11 is mature I’ll reconsider supporting Preact. Here is the published createPseudoNode function that is compatible with React, along with tests:

  • https://github.com/jaydenseric/ruck/blob/v5.0.0/createPseudoNode.mjs
  • https://github.com/jaydenseric/ruck/blob/v5.0.0/createPseudoNode.test.mjs

Here is where it is used for Ruck app hydration in the browser after SSR:

https://github.com/jaydenseric/ruck/blob/v5.0.0/hydrate.mjs#L43

Due to the different way React walks the DOM it had to be a little more complicated than the Preact implementation. It might be possible to support both React and Preact, but it would be a bit wasteful to have excess code in the implementation for the framework not being used so perhaps we would then be better off with seperate React and Preact functions. That would then require a way to specify the right function in a Ruck app depending if the author is using React or Preact (via import maps, or a ruck/serve.mjs option?).

You can see example Ruck apps here:

https://github.com/jaydenseric/ruck#examples

It's a thing of beauty to click around the routes (e.g. https://ruck.tech to https://ruck.tech/releases via the header nav link) with the browser inspector open to the document head HTML and watch the clean, minimal DOM updates thanks to full blown React rendering in the head. I haven't seen any other frameworks achieve that with head tags defined by components at render with proper SSR hydration, in just a dedicated part of the head so some head tags can still be defined in the HTML SSR template and browser extensions, analytics, etc. can inject tags in the head and not mess up the virtual DOM.

jaydenseric avatar May 17 '22 04:05 jaydenseric

A variant of this that provides subsetted hydrate(vnode, parent, children) and render(vnode, parent, children) can be found here: https://gist.github.com/developit/f321a9ef092ad39f54f8d7c8f99eb29a

@jaydenseric also see Jason's other Gist:

https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c

danielweck avatar May 17 '22 09:05 danielweck