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:
parent
7177f7579b
commit
16a3fdf931
36 changed files with 218 additions and 38 deletions
21
.changeset/yellow-snakes-jam.md
Normal file
21
.changeset/yellow-snakes-jam.md
Normal 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.
|
|
@ -1,5 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ({ children }) {
|
||||
return <div className="with-children">{children}</div>;
|
||||
}
|
|
@ -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!
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -4,5 +4,7 @@ import vue from '@astrojs/vue';
|
|||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [react(), vue()],
|
||||
});
|
||||
integrations: [react({
|
||||
experimentalReactChildren: true,
|
||||
}), vue()],
|
||||
});
|
10
packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx
vendored
Normal file
10
packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
14
packages/integrations/react/test/fixtures/react-component/src/pages/children.astro
vendored
Normal file
14
packages/integrations/react/test/fixtures/react-component/src/pages/children.astro
vendored
Normal 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>
|
|
@ -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 () => {
|
38
packages/integrations/react/vnode-children.js
Normal file
38
packages/integrations/react/vnode-children.js
Normal 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;
|
||||
}
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue