diff --git a/.changeset/nine-points-dress.md b/.changeset/nine-points-dress.md new file mode 100644 index 000000000000..1cddc36026f0 --- /dev/null +++ b/.changeset/nine-points-dress.md @@ -0,0 +1,5 @@ +--- +'@astrojs/react': patch +--- + +Fixes hydration mismatch when using `experimentalReactChildren` diff --git a/packages/integrations/react/src/client.ts b/packages/integrations/react/src/client.ts index 4938fbca1b46..efeb14a67297 100644 --- a/packages/integrations/react/src/client.ts +++ b/packages/integrations/react/src/client.ts @@ -10,16 +10,33 @@ function isAlreadyHydrated(element: HTMLElement) { } } -function createReactElementFromDOMElement(element: any): any { +const reactPropsMap: Record = { + class: 'className', + for: 'htmlFor', +}; + +let clientIds = 0; + +function createReactElementFromDOMElement(element: any, id?: number, key?: number): any { + if (id === undefined) { + clientIds += 1; + id = clientIds; + key = 0; + } + let attrs: Record = {}; for (const attr of element.attributes) { - attrs[attr.name] = attr.value; + const propName = reactPropsMap[attr.name] || attr.name; + attrs[propName] = attr.value; } - // If the element has no children, we can create a simple React element + + attrs.key = `${id}-${key}`; + if (element.firstChild === null) { return createElement(element.localName, attrs); } + let childKey = 0; return createElement( element.localName, attrs, @@ -28,7 +45,8 @@ function createReactElementFromDOMElement(element: any): any { if (c.nodeType === Node.TEXT_NODE) { return c.data; } else if (c.nodeType === Node.ELEMENT_NODE) { - return createReactElementFromDOMElement(c); + childKey += 1; + return createReactElementFromDOMElement(c, id, childKey); } else { return undefined; } 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 3f83eafcb71e..e8e820814b14 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 @@ -14,5 +14,11 @@ import WithChildren from '../components/WithChildren';
child 1
child 2
+ + + + Hello + World + diff --git a/packages/integrations/react/test/parsed-react-children.test.js b/packages/integrations/react/test/parsed-react-children.test.js index 7c81357b7230..8a3969a3e87a 100644 --- a/packages/integrations/react/test/parsed-react-children.test.js +++ b/packages/integrations/react/test/parsed-react-children.test.js @@ -13,4 +13,25 @@ describe('experimental react children', () => { const [imgVNode] = divVNode.props.children; assert.deepEqual(imgVNode.props.children, undefined); }); + + it('maps class attribute to className', () => { + const [spanVNode] = convert('Hello'); + assert.equal(spanVNode.props.className, 'title'); + assert.equal(spanVNode.props.class, undefined); + }); + + it('generates unique keys for children', () => { + const children = convert('AB'); + assert.equal(children.length, 2); + assert.ok(children[0].key, 'First child should have a key'); + assert.ok(children[1].key, 'Second child should have a key'); + assert.notEqual(children[0].key, children[1].key, 'Children should have unique keys'); + }); + + it('preserves other attributes alongside className', () => { + const [spanVNode] = convert('Hello'); + assert.equal(spanVNode.props.className, 'title'); + assert.equal(spanVNode.props.id, 'main'); + assert.equal(spanVNode.props['data-test'], 'value'); + }); }); diff --git a/packages/integrations/react/test/react-component.test.js b/packages/integrations/react/test/react-component.test.js index 0e488b2ad55a..89c0bdebe12d 100644 --- a/packages/integrations/react/test/react-component.test.js +++ b/packages/integrations/react/test/react-component.test.js @@ -119,7 +119,16 @@ describe('React Components', () => { it('Client children passes option to the client', async () => { const html = await fixture.readFile('/children/index.html'); const $ = cheerioLoad(html); - assert.equal($('[data-react-children]').length, 1); + assert.equal($('[data-react-children]').length, 2); + }); + + it('Children with class attributes are properly rendered', async () => { + const html = await fixture.readFile('/children/index.html'); + const $ = cheerioLoad(html); + assert.equal($('#three .title').length, 1); + assert.equal($('#three .subtitle').length, 1); + assert.equal($('#three .title').text(), 'Hello'); + assert.equal($('#three .subtitle').text(), 'World'); }); });