The problem
I'm trying to find a way to implement Islands architecture, with React 18.
The page I'm experimenting with looks like this
import React from 'react'
import { Counter } from './Counter'
export { Page }
function Page() {
return (
<>
<h1>Welcome</h1>
This page is:
<ul>
<li>Rendered to HTML.</li>
<li>
Non-Interactive <Counter />
Non-Interactive <Counter />
Non-Interactive <Counter />
</li>
<li>
Interactive0. <Counter island="Counter:0" /><br/>
Interactive1. <Counter island="Counter:1" /><br/>
Interactive2. <Counter island="Counter:2" /><br/>
</li>
</ul>
</>
)
}
The latter three are marked with the island=""
prop, indicating they are islands that needs to be hydrated.
What I've tried so far
The way I originally approached this task is
- Find a island
- Copy its siblings
- Hydrate the island
- Put the siblings back
Following this idea, I wrote the following client side code.
const tags = document.querySelectorAll("[island]");
for (let tag of tags) {
const nodelist_siblings_rear = [];
const nodelist_siblings_back = [];
let isRearSibling = true;
for (let sibling of tag.parentNode.childNodes) {
if (sibling.isSameNode(tag)) {
isRearSibling = false;
continue;
}
if (isRearSibling) {
nodelist_siblings_rear.push(sibling);
continue;
}
nodelist_siblings_back.push(sibling);
}
const node_parent = tag.parentNode;
const attributes = tag.attributes;
// Force React to update the dom before proceeding.
flushSync(() => {
const parsed_attr = {};
for (let item of attributes) {
// console.log(item);
parsed_attr[item.name] = item.value;
}
hydrateRoot(node_parent, <Counter {...parsed_attr} />);
console.log(node_parent);
});
const node_new =
node_parent.childNodes[0].nodeType === 3
? node_parent.childNodes[1]
: node_parent.childNodes[0];
for (let sibling of nodelist_siblings_rear) {
node_parent.insertBefore(sibling, node_new);
}
for (let sibling of nodelist_siblings_back) {
node_parent.appendChild(sibling);
}
}
In this proof of concept, the <Counter />
component worked as intended, but it also said in the console, that the hydration failed.
Hydration failed because the initial UI does not match what was rendered on the server.
EDIT:
Also, this method caused React to complain about createRoot()
being called multiple times on the same DOM element.
Warning: You are calling ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before. Instead, call root.render() on the existing root instead if you want to update it.
So I came up with other ideas, including
- Creating root and rendering in a
DocumentFragment
, and then move the child back to the DOM. This results in the component being successfully rendered but unable to react to clicks. - Creating root on the parent, and somehow hydrate the whole parent and potentially all the sibling islands. I haven't tried this idea, because it might also hydrate components under the same parent, that are not islands.
Question
- Why did the first one failed? Is there any potential issue I can look into to make it work?
- Is there any other solutions I haven't think of, that I can look into?
Edit 1
The mismatch seems to be caused by server rendering
<button type="button" island="Counter:0">
Counter
<!---->
0
<button/>
But after hydration on client side
<button type="button" island="Counter:0">
Counter
0
<button/>
That's the obvious part, I'm not sure if this is the root cause or maybe there's somethings else contributing to this error.