Skip to content

Commit

Permalink
Add experimentalReactChildren option to React integration (#8082)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update with a better test

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

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/integrations/react/README.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/integrations/react/README.md

Co-authored-by: Sarah Rainsberger <[email protected]>

---------

Co-authored-by: Nate Moore <[email protected]>
Co-authored-by: Nate Moore <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
4 people authored Aug 16, 2023
1 parent 7177f75 commit 16a3fdf
Show file tree
Hide file tree
Showing 36 changed files with 218 additions and 38 deletions.
21 changes: 21 additions & 0 deletions .changeset/yellow-snakes-jam.md
Original file line number Diff line number Diff line change
@@ -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.

This file was deleted.

40 changes: 40 additions & 0 deletions packages/integrations/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
8 changes: 6 additions & 2 deletions packages/integrations/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,19 @@
},
"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",
"@types/react-dom": "^17.0.20",
"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",
Expand Down
6 changes: 5 additions & 1 deletion packages/integrations/react/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 33 additions & 3 deletions packages/integrations/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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) });
},
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ import vue from '@astrojs/vue';

// https://astro.build/config
export default defineConfig({
integrations: [react(), vue()],
});
integrations: [react({
experimentalReactChildren: true,
}), vue()],
});
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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),
});
});

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/integrations/react/vnode-children.js
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 37 additions & 21 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 16a3fdf

Please sign in to comment.