Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save developit/f4c67a2ede71dc2fab7f357f39cff28c to your computer and use it in GitHub Desktop.
Save developit/f4c67a2ede71dc2fab7f357f39cff28c to your computer and use it in GitHub Desktop.

preact-root-fragment: partial root rendering for Preact

This is a standalone Preact 10+ implementation of the deprecated replaceNode parameter from Preact 10.

It provides a way to render or hydrate a Preact tree using a subset of the children within the parent element passed to render():

<body>
  <div id="root">  ⬅ we pass this to render() as the parent DOM element...

    <script src="/etc.js"></script>

    <div class="app">  ⬅ ... but we want to use this tree, not the script
      <!-- ... -->
    </div>

  </div>
</body>

Why do I need this?

This is particularly useful for partial hydration, which often requires rendering multiple distinct Preact trees into the same parent DOM element. Imagine the scenario below - which elements would we pass to hydrate(jsx, parent) such that each widget's <section> would get hydrated without clobbering the others?

<div id="sidebar">
  <section id="widgetA"><h1>Widget A</h1></section>
  <section id="widgetB"><h1>Widget B</h1></section>
  <section id="widgetC"><h1>Widget C</h1></section>
</div>

Preact 10 provided a somewhat obscure third argument for render and hydrate called replaceNode, which could be used for the above case:

render(<A />, sidebar, widgetA); // render into <div id="sidebar">, but only look at <section id="widgetA">
render(<B />, sidebar, widgetB); // same, but only look at widgetB
render(<C />, sidebar, widgetC); // same, but only look at widgetC

While the replaceNode argument proved useful for handling scenarios like the above, it was limited to a single DOM element and could not accommodate Preact trees with multiple root elements. It also didn't handle updates well when multiple trees were mounted into the same parent DOM element, which turns out to be a key usage scenario.

Going forward, we're providing this functionality as a standalone library called preact-root-fragment.

How it works

preact-root-fragment provides a createRootFragment function:

createRootFragment(parent: Element, children: Node | Node[]);

Calling this function with a parent DOM element and one or more child elements returns a "Persistent Fragment". A persistent fragment is a fake DOM element, which pretends to contain the provided children while keeping them in their existing real parent element. It can be passed to render() or hydrate() instead of the parent argument.

Using the previous example, we can change the deprecated replaceNode usage out for createRootFragment:

import { createRootFragment } from 'preact-root-fragment';

render(<A />, createRootFragment(sidebar, widgetA));
render(<B />, createRootFragment(sidebar, widgetB));
render(<C />, createRootFragment(sidebar, widgetC));

Since we're creating separate "Persistent Fragment" parents to pass to each render() call, Preact will treat each as an independent Virtual DOM tree.

Multiple Root Elements

Unlike the replaceNode parameter from Preact 10, createRootFragment can accept an Array of children that will be used as the root elements when rendering. This is particularly useful when rendering a Virtual DOM tree that produces multiple root elements, such as a Fragment or an Array:

import { createRootFragment } from 'preact-root-fragment';
import { render } from 'preact';

function App() {
  return <>
    <h1>Example</h1>
    <p>Hello world!</p>
  </>;
}

// Use only the last two child elements within <body>:
const children = [].slice.call(document.body.children, -2);

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

Preact Version Support

This library works with Preact 10 and 11.

Changelog

0.2.0 (2022-03-04)

  • fix bug where nodes were appended instead of replaced (thanks @danielweck)
  • fix .__k assignment (thanks @danielweck)
  • fix Preact 10.6 debug error due to missing nodeType (thanks @danielweck)
{
"name": "preact-root-fragment",
"version": "0.2.0",
"main": "./preact-root-fragment.js",
"module": "./preact-root-fragment.js",
"type": "module"
}
/**
* A Preact 11+ implementation of the `replaceNode` parameter from Preact 10.
*
* This creates a "Persistent Fragment" (a fake DOM element) containing one or more
* DOM nodes, which can then be passed as the `parent` argument to Preact's `render()` method.
*/
export function createRootFragment(parent, replaceNode) {
replaceNode = [].concat(replaceNode);
var s = replaceNode[replaceNode.length-1].nextSibling;
function insert(c, r) { parent.insertBefore(c, r || s); }
return parent.__k = {
nodeType: 1,
parentNode: parent,
firstChild: replaceNode[0],
childNodes: replaceNode,
insertBefore: insert,
appendChild: insert,
removeChild: function(c) { parent.removeChild(c); }
};
}
@danielweck
Copy link

BUG REPORT: the parent.__k object must have property nodeType: 1 otherwise Preact render() throws "Uncaught Error: Expected a valid HTML node as a second argument to render. Received [object Object] instead"

@danielweck
Copy link

Oh, I forgot to mention, this is with Preact version 10.6.6 in the debug component:
https://github.com/preactjs/preact/blob/10.6.6/debug/src/debug.js#L94-L110

@danielweck
Copy link

According to my tests, here is another bug with Preact v10.6.6: the parent.__k object must have a firstChild property that returns replaceNode[0], otherwise the rendered component is "appended" (i.e. inserted immediately before nextSibling, which leaves the replaceNode(s) intact in the DOM tree) instead of replacing the targeted replaceNode(s).

@danielweck
Copy link

Finally, the "caching" of .__k on parent (i.e. return parent.__k || (parent.__k = { ... })) causes failure to render() more than once, as replaceNode typically changes in the closure when createRootFragment() is invoked multiple times. My solution is to recreate parent.__k every time.

@developit
Copy link
Author

Awesome bug report, thanks @danielweck. I've just updated the gist+version with all three changes.

@MicahZoltu
Copy link

This solution, which is the recommended replacement for replacing nodes in preact 11, is not actually compatible with render.

render wants a Element | Document | ShadowRoot | DocumentFragment as its second parameter and this does not return an object that matches that interface. While this might work at the moment if render happens to only look at a subset of the required properties on the second parameter, this code is incredibly fragile because a patch version bump to preact may start utilizing additional properties on the supplied object that aren't provided by this function.

I don't know if this is fixable with this gist, or if it requires preact to narrow the type of the second parameter to render.


Also NodeList<ChildNode> is required for childNodes, and this code is passing a ChildNode[] instead. This suffers from the same issue as above, except now a browser update or any third party library may unexpectedly break if it expects a NodeList and gets an [] instead.

@developit
Copy link
Author

@MicahZoltu what you have described is a TypeScript issue, and this code is not TypeScript. Preact does not (will will not) use properties other than those present here - in fact we are moving away from relying on all DOM properties for reads.

@MicahZoltu
Copy link

MicahZoltu commented Jan 15, 2023

The issue is that this code violates the API provided by preact, TypeScript is just the way that API is expressed. From your comment, it sounds like the issue here is that the preact types provided with the library don't align with the actual API that the preact developers intend to express.

If preact is intending to make a guarantee that it won't depend on any properties not included here, then I will look into submitting a PR to preact to fix the exported types.

@MicahZoltu
Copy link

@joyqi
Copy link

joyqi commented May 5, 2023

For those who are looking for the TypeScript version

import { ContainerNode } from "preact";
  
/**
 * A Preact 11+ implementation of the `replaceNode` parameter from Preact 10.
 *
 * This creates a "Persistent Fragment" (a fake DOM element) containing one or more
 * DOM nodes, which can then be passed as the `parent` argument to Preact's `render()` method.
 */
export function createRootFragment(parent: Node, replaceNode?: Node | Node[]): ContainerNode {
    if (replaceNode) {
        replaceNode = Array.isArray(replaceNode) ? replaceNode : [replaceNode];
    } else {
        replaceNode = [parent];
        parent = parent.parentNode as Node;
    }

    const s: Node | null = replaceNode[replaceNode.length - 1].nextSibling;

    const rootFragment: ContainerNode = {
        nodeType: 1,
        parentNode: parent as ParentNode,
        firstChild: replaceNode[0] as ChildNode,
        childNodes: replaceNode,
        insertBefore: (c, r) => {
            parent.insertBefore(c, r || s);
            return c;
        },
        appendChild: (c) => {
            parent.insertBefore(c, s);
            return c;
        },
        removeChild: function (c) {
            parent.removeChild(c);
            return c;
        },
    };
  
    (parent as any).__k = rootFragment;
    return rootFragment;
}

@sebastian-lenz
Copy link

This seems to no longer work with preact version 10.16.

@foxt
Copy link

foxt commented Oct 18, 2023

the JSDoc for the regular render(component, element) seems to lead to this page? Looking at the .d.ts makes it look to me as this was not intended?
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment