diff --git a/.changeset/real-dryers-destroy.md b/.changeset/real-dryers-destroy.md new file mode 100644 index 000000000000..d5195b4a4dea --- /dev/null +++ b/.changeset/real-dryers-destroy.md @@ -0,0 +1,5 @@ +--- +'@astrojs/react': patch +--- + +Fixes client hydration in islands when using experimentalReactChildren diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js index dbd32c0c5a54..73f43aa94563 100644 --- a/packages/integrations/react/client.js +++ b/packages/integrations/react/client.js @@ -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; @@ -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 diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index c2400accb673..26596289eb8c 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -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) { diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx index 500c0c6949b3..a522bf95e9f1 100644 --- a/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx +++ b/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx @@ -1,8 +1,8 @@ import React from 'react'; -export default function ({ children }) { +export default function ({ id, children }) { return ( -
+
{children}
{children.length}
diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro index 59595c26642c..3f83eafcb71e 100644 --- a/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro @@ -7,7 +7,11 @@ import WithChildren from '../components/WithChildren'; - + +
child 1
child 2
+
+ +
child 1
child 2
diff --git a/packages/integrations/react/test/react-component.test.js b/packages/integrations/react/test/react-component.test.js index 43df1d9e4f0e..96d60fd54d20 100644 --- a/packages/integrations/react/test/react-component.test.js +++ b/packages/integrations/react/test/react-component.test.js @@ -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); }); }); diff --git a/packages/integrations/react/vnode-children.js b/packages/integrations/react/vnode-children.js index ea5bc0869ee6..57a7fb66ff56 100644 --- a/packages/integrations/react/vnode-children.js +++ b/packages/integrations/react/vnode-children.js @@ -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; }