Add experimentalReactChildren option to React integration (#8082)

* wip: support true react vnodes in renderer

* Add new experimentalReactChildren option to React integration

* Update the test

* Add docs

* Update packages/integrations/react/server.js

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* Update with a better test

* Update .changeset/yellow-snakes-jam.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/integrations/react/README.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update packages/integrations/react/README.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

---------

Co-authored-by: Nate Moore <nate@astro.build>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Matthew Phillips 2023-08-16 13:40:57 -04:00 committed by GitHub
parent 7177f7579b
commit 16a3fdf931
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 218 additions and 38 deletions

View file

@ -0,0 +1,21 @@
---
'@astrojs/react': minor
---
Optionally parse React slots as React children.
This adds a new configuration option for the React integration `experimentalReactChildren`:
```js
export default {
integrations: [
react({
experimentalReactChildren: true,
})
]
}
```
With this enabled, children passed to React from Astro components via the default slot are parsed as React components.
This enables better compatibility with certain React components which manipulate their children.

View file

@ -1,5 +0,0 @@
import React from 'react';
export default function ({ children }) {
return <div className="with-children">{children}</div>;
}

View file

@ -61,6 +61,46 @@ To use your first React component in Astro, head to our [UI framework documentat
- 💧 client-side hydration options, and
- 🤝 opportunities to mix and nest frameworks together
## Options
### Children parsing
Children passed into a React component from an Astro component are parsed as plain strings, not React nodes.
For example, the `<ReactComponent />` below will only receive a single child element:
```astro
---
import ReactComponent from './ReactComponent';
---
<ReactComponent>
<div>one</div>
<div>two</div>
</ReactComponent>
```
If you are using a library that *expects* more than one child element element to be passed, for example so that it can slot certain elements in different places, you might find this to be a blocker.
You can set the experimental flag `experimentalReactChildren` to tell Astro to always pass children to React as React vnodes. There is some runtime cost to this, but it can help with compatibility.
You can enable this option in the configuration for the React integration:
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
// ...
integrations: [
react({
experimentalReactChildren: true,
})
],
});
```
## Troubleshooting
For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!

View file

@ -45,7 +45,8 @@
},
"dependencies": {
"@babel/core": "^7.22.5",
"@babel/plugin-transform-react-jsx": "^7.22.5"
"@babel/plugin-transform-react-jsx": "^7.22.5",
"ultrahtml": "^1.2.0"
},
"devDependencies": {
"@types/react": "^17.0.62",
@ -53,7 +54,10 @@
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"react": "^18.1.0",
"react-dom": "^18.1.0"
"react-dom": "^18.1.0",
"chai": "^4.3.7",
"cheerio": "1.0.0-rc.12",
"vite": "^4.4.6"
},
"peerDependencies": {
"@types/react": "^17.0.50 || ^18.0.21",

View file

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/server';
import StaticHtml from './static-html.js';
import { incrementId } from './context.js';
import opts from 'astro:react:opts';
const slotName = (str) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');
@ -85,7 +86,10 @@ async function renderToStaticMarkup(Component, props, { default: children, ...sl
...slots,
};
const newChildren = children ?? props.children;
if (newChildren != null) {
if (children && opts.experimentalReactChildren) {
const convert = await import('./vnode-children.js').then(mod => mod.default);
newProps.children = convert(children);
} else if (newChildren != null) {
newProps.children = React.createElement(StaticHtml, {
hydrate: needsHydration(metadata),
value: newChildren,

View file

@ -1,5 +1,6 @@
import type { AstroIntegration } from 'astro';
import { version as ReactVersion } from 'react-dom';
import type * as vite from 'vite';
function getRenderer() {
return {
@ -36,7 +37,29 @@ function getRenderer() {
};
}
function getViteConfiguration() {
function optionsPlugin(experimentalReactChildren: boolean): vite.Plugin {
const virtualModule = 'astro:react:opts';
const virtualModuleId = '\0' + virtualModule;
return {
name: '@astrojs/react:opts',
resolveId(id) {
if(id === virtualModule) {
return virtualModuleId;
}
},
load(id) {
if(id === virtualModuleId) {
return {
code: `export default {
experimentalReactChildren: ${JSON.stringify(experimentalReactChildren)}
}`
};
}
}
};
}
function getViteConfiguration(experimentalReactChildren: boolean) {
return {
optimizeDeps: {
include: [
@ -70,16 +93,23 @@ function getViteConfiguration() {
'use-immer',
],
},
plugins: [
optionsPlugin(experimentalReactChildren)
]
};
}
export default function (): AstroIntegration {
export type ReactIntegrationOptions = {
experimentalReactChildren: boolean;
}
export default function ({ experimentalReactChildren }: ReactIntegrationOptions = { experimentalReactChildren: false }): AstroIntegration {
return {
name: '@astrojs/react',
hooks: {
'astro:config:setup': ({ addRenderer, updateConfig }) => {
addRenderer(getRenderer());
updateConfig({ vite: getViteConfiguration() });
updateConfig({ vite: getViteConfiguration(experimentalReactChildren) });
},
},
};

View file

@ -4,5 +4,7 @@ import vue from '@astrojs/vue';
// https://astro.build/config
export default defineConfig({
integrations: [react(), vue()],
});
integrations: [react({
experimentalReactChildren: true,
}), vue()],
});

View file

@ -0,0 +1,10 @@
import React from 'react';
export default function ({ children }) {
return (
<div>
<div className="with-children">{children}</div>
<div className="with-children-count">{children.length}</div>
</div>
);
}

View file

@ -0,0 +1,14 @@
---
import WithChildren from '../components/WithChildren';
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<WithChildren>
<div>child 1</div><div>child 2</div>
</WithChildren>
</body>
</html>

View file

@ -1,13 +1,13 @@
import { expect } from 'chai';
import { load as cheerioLoad } from 'cheerio';
import { isWindows, loadFixture } from './test-utils.js';
import { isWindows, loadFixture } from '../../../astro/test/test-utils.js';
let fixture;
describe('React Components', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/react-component/',
root: new URL('./fixtures/react-component/', import.meta.url),
});
});
@ -51,7 +51,7 @@ describe('React Components', () => {
// test 10: Should properly render children passed as props
const islandsWithChildren = $('.with-children');
expect(islandsWithChildren).to.have.lengthOf(2);
expect($(islandsWithChildren[0]).html()).to.equal($(islandsWithChildren[1]).html());
expect($(islandsWithChildren[0]).html()).to.equal($(islandsWithChildren[1]).find('astro-slot').html());
// test 11: Should generate unique React.useId per island
const islandsWithId = $('.react-use-id');
@ -99,12 +99,18 @@ describe('React Components', () => {
const $ = cheerioLoad(html);
expect($('#cloned').text()).to.equal('Cloned With Props');
});
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');
})
});
if (isWindows) return;
describe('dev', () => {
/** @type {import('./test-utils').Fixture} */
/** @type {import('../../../astro/test/test-utils.js').Fixture} */
let devServer;
before(async () => {

View file

@ -0,0 +1,38 @@
import { parse, walkSync, DOCUMENT_NODE, ELEMENT_NODE, TEXT_NODE } from 'ultrahtml'
import { createElement, Fragment } from 'react';
export default function convert(children) {
const nodeMap = new WeakMap();
let doc = parse(children.toString().trim());
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;
newNode = createElement(node.name, { ...props, className, children: [] });
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.trim();
if (newNode.trim()) {
if (parent) {
const newParent = nodeMap.get(parent);
if (parent.children.length === 1) {
newParent.props.children[0] = newNode;
} else {
newParent.props.children[index] = newNode;
}
}
}
}
});
return root.props.children;
}

View file

@ -3000,27 +3000,6 @@ importers:
specifier: ^1.7.6
version: 1.7.6
packages/astro/test/fixtures/react-component:
dependencies:
'@astrojs/react':
specifier: workspace:*
version: link:../../../../integrations/react
'@astrojs/vue':
specifier: workspace:*
version: link:../../../../integrations/vue
astro:
specifier: workspace:*
version: link:../../..
react:
specifier: ^18.1.0
version: 18.2.0
react-dom:
specifier: ^18.1.0
version: 18.2.0(react@18.2.0)
vue:
specifier: ^3.3.4
version: 3.3.4
packages/astro/test/fixtures/react-jsx-export:
dependencies:
react:
@ -4787,6 +4766,9 @@ importers:
'@babel/plugin-transform-react-jsx':
specifier: ^7.22.5
version: 7.22.5(@babel/core@7.22.5)
ultrahtml:
specifier: ^1.2.0
version: 1.2.0
devDependencies:
'@types/react':
specifier: ^17.0.62
@ -4800,12 +4782,42 @@ importers:
astro-scripts:
specifier: workspace:*
version: link:../../../scripts
chai:
specifier: ^4.3.7
version: 4.3.7
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
react:
specifier: ^18.1.0
version: 18.2.0
react-dom:
specifier: ^18.1.0
version: 18.2.0(react@18.2.0)
vite:
specifier: ^4.4.6
version: 4.4.6(@types/node@18.16.18)(sass@1.63.4)
packages/integrations/react/test/fixtures/react-component:
dependencies:
'@astrojs/react':
specifier: workspace:*
version: link:../../..
'@astrojs/vue':
specifier: workspace:*
version: link:../../../../vue
astro:
specifier: workspace:*
version: link:../../../../../astro
react:
specifier: ^18.1.0
version: 18.2.0
react-dom:
specifier: ^18.1.0
version: 18.2.0(react@18.2.0)
vue:
specifier: ^3.3.4
version: 3.3.4
packages/integrations/sitemap:
dependencies:
@ -17374,6 +17386,10 @@ packages:
resolution: {integrity: sha512-P24ulZdT9UKyQuKA1IApdAZ+F9lwruGvmKb4pG3+sMvR3CjN0pjawPnxuSABHQFB+XqnB35TVXzJPOBYjCv6Kw==}
dev: false
/ultrahtml@1.2.0:
resolution: {integrity: sha512-vxZM2yNvajRmCj/SknRYGNXk2tqiy6kRNvZjJLaleG3zJbSh/aNkOqD1/CVzypw8tyHyhpzYuwQgMMhUB4ZVNQ==}
dev: false
/unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies: