Open jaydenseric opened 3 years ago
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
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:
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?
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 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.
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);
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.
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:
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.
A variant of this that provides subsetted
hydrate(vnode, parent, children)
andrender(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
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:With HTML like this:
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:
And use it to hydrate the head Preact app:
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 realdocument.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:
Using
.valueOf()
on a real DOM node likedocument.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 thecase "valueOf"
in thecreateVirtualNode
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 orVirtualNode
class that can be used as the app root forhydrate
orrender
, 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.