Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimentalReactChildren option to React integration #8082

Merged
merged 9 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

Loading