Skip to content

Commit

Permalink
Fix client hydration in experimentalReactChildren (#8898)
Browse files Browse the repository at this point in the history
* Fix client hydration in experimentalReactChildren

* Add tests

* Add a changeset

* Use recursion instead of walking

* getChildren -> swap order

---------

Co-authored-by: Nate Moore <[email protected]>
  • Loading branch information
matthewp and natemoo-re committed Nov 22, 2023
1 parent 4291b1c commit 21d6716
Show file tree
Hide file tree
Showing 7 changed files with 75 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/real-dryers-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/react': patch
---

Fixes client hydration in islands when using experimentalReactChildren
39 changes: 38 additions & 1 deletion packages/integrations/react/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ function isAlreadyHydrated(element) {
}
}

function createReactElementFromDOMElement(element) {
let attrs = {};
for(const attr of element.attributes) {
attrs[attr.name] = attr.value;
}

return createElement(element.localName, attrs,
Array.from(element.childNodes).map(c => {
if(c.nodeType === Node.TEXT_NODE) {
return c.data;
} else if(c.nodeType === Node.ELEMENT_NODE) {
return createReactElementFromDOMElement(c)
} else {
return undefined;
}
}).filter(a => !!a)
);
}

function getChildren(childString, experimentalReactChildren) {
if(experimentalReactChildren && childString) {
let children = [];
let template = document.createElement('template');
template.innerHTML = childString;
for(let child of template.content.children) {
children.push(createReactElementFromDOMElement(child))
}
return children;
} else if(childString) {
return createElement(StaticHtml, { value: childString });
} else {
return undefined;
}

}

export default (element) =>
(Component, props, { default: children, ...slotted }, { client }) => {
if (!element.hasAttribute('ssr')) return;
Expand All @@ -19,10 +55,11 @@ export default (element) =>
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
}

const componentEl = createElement(
Component,
props,
children != null ? createElement(StaticHtml, { value: children }) : children
getChildren(children, element.hasAttribute('data-react-children'))
);
const rootKey = isAlreadyHydrated(element);
// HACK: delete internal react marker for nested components to suppress aggressive warnings
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/react/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
};
const newChildren = children ?? props.children;
if (children && opts.experimentalReactChildren) {
attrs['data-react-children'] = true;
const convert = await import('./vnode-children.js').then((mod) => mod.default);
newProps.children = convert(children);
} else if (newChildren != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';

export default function ({ children }) {
export default function ({ id, children }) {
return (
<div>
<div id={id}>
<div className="with-children">{children}</div>
<div className="with-children-count">{children.length}</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import WithChildren from '../components/WithChildren';
<!-- Head Stuff -->
</head>
<body>
<WithChildren>
<WithChildren id="one">
<div>child 1</div><div>child 2</div>
</WithChildren>

<WithChildren id="two" client:load>
<div>child 1</div><div>child 2</div>
</WithChildren>
</body>
Expand Down
8 changes: 7 additions & 1 deletion packages/integrations/react/test/react-component.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ describe('React Components', () => {
it('Children are parsed as React components, can be manipulated', async () => {
const html = await fixture.readFile('/children/index.html');
const $ = cheerioLoad(html);
expect($('.with-children-count').text()).to.equal('2');
expect($('#one .with-children-count').text()).to.equal('2');
});

it('Client children passes option to the client', async () => {
const html = await fixture.readFile('/children/index.html');
const $ = cheerioLoad(html);
expect($('[data-react-children]')).to.have.lengthOf(1);
});
});

Expand Down
39 changes: 17 additions & 22 deletions packages/integrations/react/vnode-children.js
Original file line number Diff line number Diff line change
@@ -1,35 +1,30 @@
import { parse, walkSync, DOCUMENT_NODE, ELEMENT_NODE, TEXT_NODE } from 'ultrahtml';
import { parse, DOCUMENT_NODE, ELEMENT_NODE, TEXT_NODE } from 'ultrahtml';
import { createElement, Fragment } from 'react';

let ids = 0;
export default function convert(children) {
const nodeMap = new WeakMap();
let doc = parse(children.toString().trim());
let id = ids++;
let key = 0;
let root = createElement(Fragment, { children: [] });

walkSync(doc, (node, parent, index) => {
let newNode = {};
if (node.type === DOCUMENT_NODE) {
nodeMap.set(node, root);
} else if (node.type === ELEMENT_NODE) {
const { class: className, ...props } = node.attributes;
// NOTE: do not manually pass `children`, React handles this internally
newNode = createElement(node.name, { ...props, className, key: `${id}-${key++}` });
nodeMap.set(node, newNode);
if (parent) {
const newParent = nodeMap.get(parent);
newParent.props.children[index] = newNode;
}
} else if (node.type === TEXT_NODE) {
newNode = node.value;
if (newNode.trim() && parent) {
const newParent = nodeMap.get(parent);
newParent.props.children[index] = newNode;
function createReactElementFromNode(node) {
const childVnodes = Array.isArray(node.children) ? node.children.map(child => {
if(child.type === ELEMENT_NODE) {
return createReactElementFromNode(child);
} else if(child.type === TEXT_NODE) {
// 0-length text gets omitted in JSX
return child.value.trim() ? child.value : undefined;
}
}).filter(n => !!n) : undefined;

if(node.type === DOCUMENT_NODE) {
return createElement(Fragment, {}, childVnodes);
} else if(node.type === ELEMENT_NODE) {
const { class: className, ...props } = node.attributes;
return createElement(node.name, { ...props, className, key: `${id}-${key++}` }, childVnodes);
}
});
}

const root = createReactElementFromNode(doc);
return root.props.children;
}

0 comments on commit 21d6716

Please sign in to comment.