diff options
author | 2023-08-17 08:54:28 -0400 | |
---|---|---|
committer | 2023-08-17 08:54:28 -0400 | |
commit | cbb77af978bd0dcee08ad2dcadadb032abc44dc1 (patch) | |
tree | 94b7f35fd4214bbcdb1d36393583c5332bc5ff24 /packages/integrations/react | |
parent | 2484dc4080e5cd84b9a53648a1de426d7c907be2 (diff) | |
parent | d6b4943764989c0e89df2d6875cd19691566dfb3 (diff) | |
download | astro-cbb77af978bd0dcee08ad2dcadadb032abc44dc1.tar.gz astro-cbb77af978bd0dcee08ad2dcadadb032abc44dc1.tar.zst astro-cbb77af978bd0dcee08ad2dcadadb032abc44dc1.zip |
Merge branch 'main' into next
Diffstat (limited to 'packages/integrations/react')
34 files changed, 584 insertions, 8 deletions
diff --git a/packages/integrations/react/CHANGELOG.md b/packages/integrations/react/CHANGELOG.md index cf056a58f..146d36427 100644 --- a/packages/integrations/react/CHANGELOG.md +++ b/packages/integrations/react/CHANGELOG.md @@ -23,6 +23,12 @@ - [`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023. +## 2.2.2 + +### Patch Changes + +- [#8075](https://github.com/withastro/astro/pull/8075) [`da517d405`](https://github.com/withastro/astro/commit/da517d4055825ee1b630cd4a6983818d6120a7b7) Thanks [@SudoCat](https://github.com/SudoCat)! - fix a bug where react identifierPrefix was set to null for client:only components causing React.useId to generate ids prefixed with null + ## 2.2.1 ### Patch Changes diff --git a/packages/integrations/react/README.md b/packages/integrations/react/README.md index 48c45881f..8009972b3 100644 --- a/packages/integrations/react/README.md +++ b/packages/integrations/react/README.md @@ -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! diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 87fd46f94..302ac4ae0 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -45,7 +45,8 @@ }, "dependencies": { "@astrojs/internal-helpers": "0.2.0-beta.1", - "@vitejs/plugin-react": "^4.0.3" + "@vitejs/plugin-react": "^4.0.3", + "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", diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index 8c02c4b26..c2400accb 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -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, diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts index f5332e2ed..a318bb4c2 100644 --- a/packages/integrations/react/src/index.ts +++ b/packages/integrations/react/src/index.ts @@ -2,9 +2,13 @@ import type { AstroIntegration } from 'astro'; import { version as ReactVersion } from 'react-dom'; import react, { type Options as ViteReactPluginOptions } from '@vitejs/plugin-react'; import { appendForwardSlash } from '@astrojs/internal-helpers/path'; +import type * as vite from 'vite'; const FAST_REFRESH_PREAMBLE = react.preambleCode; + + + function getRenderer() { return { name: '@astrojs/react', @@ -17,7 +21,29 @@ function getRenderer() { }; } -function getViteConfiguration({ include, exclude }: Options = {}) { +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, { include, exclude }: Options = {}) { return { optimizeDeps: { include: [ @@ -35,7 +61,10 @@ function getViteConfiguration({ include, exclude }: Options = {}) { : '@astrojs/react/server-v17.js', ], }, - plugins: [react({ include, exclude })], + plugins: [ + react({ include, exclude }), + optionsPlugin(experimentalReactChildren) + ], resolve: { dedupe: ['react', 'react-dom', 'react-dom/server'], }, @@ -55,17 +84,22 @@ function getViteConfiguration({ include, exclude }: Options = {}) { }; } -export type Options = Pick<ViteReactPluginOptions, 'include' | 'exclude'>; +export type ReactIntegrationOptions = Pick<ViteReactPluginOptions, 'include' | 'exclude'> & { + experimentalReactChildren: boolean; +}; export default function ({ include, exclude, -}: Pick<ViteReactPluginOptions, 'include' | 'exclude'> = {}): AstroIntegration { + experimentalReactChildren +}: ReactIntegrationOptions = { + experimentalReactChildren: false +}): AstroIntegration { return { name: '@astrojs/react', hooks: { 'astro:config:setup': ({ config, command, addRenderer, updateConfig, injectScript }) => { addRenderer(getRenderer()); - updateConfig({ vite: getViteConfiguration({ include, exclude }) }); + updateConfig({ vite: getViteConfiguration(experimentalReactChildren, { include, exclude }) }); if (command === 'dev') { const preamble = FAST_REFRESH_PREAMBLE.replace( `__BASE__`, diff --git a/packages/integrations/react/test/fixtures/react-component/astro.config.mjs b/packages/integrations/react/test/fixtures/react-component/astro.config.mjs new file mode 100644 index 000000000..cd54d60f8 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/astro.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; +import vue from '@astrojs/vue'; + +// https://astro.build/config +export default defineConfig({ + integrations: [react({ + experimentalReactChildren: true, + }), vue()], +}); diff --git a/packages/integrations/react/test/fixtures/react-component/package.json b/packages/integrations/react/test/fixtures/react-component/package.json new file mode 100644 index 000000000..cf7b2b057 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/package.json @@ -0,0 +1,13 @@ +{ + "name": "@test/react-component", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/react": "workspace:*", + "@astrojs/vue": "workspace:*", + "astro": "workspace:*", + "react": "^18.1.0", + "react-dom": "^18.1.0", + "vue": "^3.3.4" + } +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/ArrowFunction.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ArrowFunction.jsx new file mode 100644 index 000000000..16fac5bb6 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ArrowFunction.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default () => { + return <div id="arrow-fn-component"></div>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/CloneElement.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/CloneElement.jsx new file mode 100644 index 000000000..809ac4aa4 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/CloneElement.jsx @@ -0,0 +1,6 @@ +import { cloneElement } from 'react'; + +const ClonedWithProps = (element) => (props) => + cloneElement(element, props); + +export default ClonedWithProps(<div id="cloned">Cloned With Props</div>); diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/ForgotImport.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ForgotImport.jsx new file mode 100644 index 000000000..9ee27faca --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ForgotImport.jsx @@ -0,0 +1,3 @@ +export default function ({}) { + return <h2>oops</h2>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/GetSearch.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/GetSearch.jsx new file mode 100644 index 000000000..d3fee2f9a --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/GetSearch.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +function GetSearch() { + return (<div>{window.location.search}</div>); +} + +export default GetSearch diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/Goodbye.vue b/packages/integrations/react/test/fixtures/react-component/src/components/Goodbye.vue new file mode 100644 index 000000000..430dfdb71 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Goodbye.vue @@ -0,0 +1,11 @@ +<template> + <h2 id="vue-h2">Hasta la vista, {{ name }}</h2> +</template> + +<script> +export default { + props: { + name: String, + }, +}; +</script> diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/Hello.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Hello.jsx new file mode 100644 index 000000000..4c241162d --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Hello.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function ({ name, unused }) { + return <h2 id={`react-${name}`}>Hello {name}!</h2>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx new file mode 100644 index 000000000..d6ff21dc3 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ImportsThrowsAnError.jsx @@ -0,0 +1,7 @@ +import ThrowsAnError from "./ThrowsAnError"; + +export default function() { + return <> + <ThrowsAnError /> + </> +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/LazyComponent.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/LazyComponent.jsx new file mode 100644 index 000000000..b43aa36be --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/LazyComponent.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export const LazyComponent = () => { + return ( + <span id="lazy">inner content</span> + ); +}; + +export default LazyComponent; diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/PragmaComment.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaComment.jsx new file mode 100644 index 000000000..d8ea77810 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaComment.jsx @@ -0,0 +1,5 @@ +/** @jsxImportSource react */ + +export default function() { + return <div className="pragma-comment">Hello world</div>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx new file mode 100644 index 000000000..9f2256fbf --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/PragmaCommentTypeScript.tsx @@ -0,0 +1,5 @@ +/** @jsxImportSource react */ + +export default function({}: object) { + return <div className="pragma-comment">Hello world</div>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/PropsSpread.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/PropsSpread.jsx new file mode 100644 index 000000000..044c2a019 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/PropsSpread.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default (props) => { + return <div id="component-spread-props">{props.text}</div>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/Pure.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Pure.jsx new file mode 100644 index 000000000..6fae8613b --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Pure.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default class StaticComponent extends React.PureComponent { + + render() { + return ( + <div id="pure"> + <h1>Static component</h1> + </div> + ) + } + +}
\ No newline at end of file diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/Research.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Research.jsx new file mode 100644 index 000000000..9ab83e5f3 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Research.jsx @@ -0,0 +1,7 @@ +import * as React from 'react' + +export function Research2() { + const [value] = React.useState(1) + + return <div id="research">foo bar {value}</div> +}
\ No newline at end of file diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/Suspense.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/Suspense.jsx new file mode 100644 index 000000000..87dc82625 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/Suspense.jsx @@ -0,0 +1,14 @@ +import React, { Suspense } from 'react'; +const LazyComponent = React.lazy(() => import('./LazyComponent.jsx')); + +export const ParentComponent = () => { + return ( + <div id="outer"> + <Suspense> + <LazyComponent /> + </Suspense> + </div> + ); +}; + +export default ParentComponent; diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/ThrowsAnError.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/ThrowsAnError.jsx new file mode 100644 index 000000000..cf970e38c --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/ThrowsAnError.jsx @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +export default function() { + let player = undefined; + // This is tested in dev mode, so make it work during the build to prevent + // breaking other tests. + if(import.meta.env.MODE === 'production') { + player = {}; + } + const [] = useState(player.currentTime || null); + + return ( + <div>Should have thrown</div> + ) +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/TypeScriptComponent.tsx b/packages/integrations/react/test/fixtures/react-component/src/components/TypeScriptComponent.tsx new file mode 100644 index 000000000..bde96da84 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/TypeScriptComponent.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function({}) { + return <div className="ts-component">Hello world</div>; +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx new file mode 100644 index 000000000..500c0c694 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/WithChildren.jsx @@ -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> + ); +} diff --git a/packages/integrations/react/test/fixtures/react-component/src/components/WithId.jsx b/packages/integrations/react/test/fixtures/react-component/src/components/WithId.jsx new file mode 100644 index 000000000..0abe91c72 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/components/WithId.jsx @@ -0,0 +1,6 @@ +import React from 'react'; + +export default function () { + const id = React.useId(); + return <p className='react-use-id' id={id}>{id}</p>; +} 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 new file mode 100644 index 000000000..59595c266 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/children.astro @@ -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> diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/error-rendering.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/error-rendering.astro new file mode 100644 index 000000000..6984a6da5 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/error-rendering.astro @@ -0,0 +1,11 @@ +--- +import ImportsThrowsAnError from '../components/ImportsThrowsAnError'; +--- +<html> +<head> + <title>Testing</title> +</head> +<body> + <ImportsThrowsAnError /> +</body> +</html> diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro new file mode 100644 index 000000000..3afd8233f --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/index.astro @@ -0,0 +1,41 @@ +--- +import Hello from '../components/Hello.jsx'; +import Later from '../components/Goodbye.vue'; +import ArrowFunction from '../components/ArrowFunction.jsx'; +import PropsSpread from '../components/PropsSpread.jsx'; +import {Research2} from '../components/Research.jsx'; +import Pure from '../components/Pure.jsx'; +import TypeScriptComponent from '../components/TypeScriptComponent'; +import CloneElement from '../components/CloneElement'; +import WithChildren from '../components/WithChildren'; +import WithId from '../components/WithId'; + +const someProps = { + text: 'Hello world!', +}; +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <Hello name="static" /> + <Hello name="load" client:load /> + <!-- Test island deduplication, i.e. same UID as the component above. --> + <Hello name="load" client:load /> + <!-- Test island deduplication account for non-render affecting props. --> + <Hello name="load" unused="" client:load /> + <Later name="baby" /> + <ArrowFunction /> + <PropsSpread {...someProps}/> + <Research2 client:idle /> + <TypeScriptComponent client:load /> + <Pure /> + <CloneElement /> + <WithChildren client:load>test</WithChildren> + <WithChildren client:load children="test" /> + <WithId client:idle /> + <WithId client:idle /> + </body> +</html> diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/pragma-comment.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/pragma-comment.astro new file mode 100644 index 000000000..b3ddba639 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/pragma-comment.astro @@ -0,0 +1,14 @@ +--- +import PragmaComponent from '../components/PragmaComment.jsx'; +import PragmaComponentTypeScript from '../components/PragmaCommentTypeScript.tsx'; +--- + +<html> +<head> + <title>React component works with Pragma comment</title> +</head> +<body> + <PragmaComponent client:load/> + <PragmaComponentTypeScript client:load/> +</body> +</html> diff --git a/packages/integrations/react/test/fixtures/react-component/src/pages/suspense.astro b/packages/integrations/react/test/fixtures/react-component/src/pages/suspense.astro new file mode 100644 index 000000000..5a9d15644 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/pages/suspense.astro @@ -0,0 +1,17 @@ +--- +import Suspense from '../components/Suspense.jsx'; +--- + +<html> + <head> + <!-- Head Stuff --> + </head> + <body> + <div id="client"> + <Suspense client:load /> + </div> + <div id="server"> + <Suspense /> + </div> + </body> +</html> diff --git a/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/forgot-import.astro b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/forgot-import.astro new file mode 100644 index 000000000..de5d319d9 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/forgot-import.astro @@ -0,0 +1,12 @@ +--- +import ForgotImport from '../components/ForgotImport.jsx'; +--- + +<html> +<head> + <title>Here we are</title> +</head> +<body> + <ForgotImport /> +</body> +</html>
\ No newline at end of file diff --git a/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/window.astro b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/window.astro new file mode 100644 index 000000000..e780f3c44 --- /dev/null +++ b/packages/integrations/react/test/fixtures/react-component/src/skipped-pages/window.astro @@ -0,0 +1,8 @@ +--- +import GetSearch from '../components/GetSearch.jsx'; +--- +<html> +<body> + <GetSearch /> +</body> +</html> diff --git a/packages/integrations/react/test/react-component.test.js b/packages/integrations/react/test/react-component.test.js new file mode 100644 index 000000000..43df1d9e4 --- /dev/null +++ b/packages/integrations/react/test/react-component.test.js @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import { load as cheerioLoad } from 'cheerio'; +import { isWindows, loadFixture } from '../../../astro/test/test-utils.js'; + +let fixture; + +describe('React Components', () => { + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/react-component/', import.meta.url), + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('Can load React', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + // test 1: basic component renders + expect($('#react-static').text()).to.equal('Hello static!'); + + // test 2: no reactroot + expect($('#react-static').attr('data-reactroot')).to.equal(undefined); + + // test 3: Can use function components + expect($('#arrow-fn-component')).to.have.lengthOf(1); + + // test 4: Can use spread for components + expect($('#component-spread-props')).to.have.lengthOf(1); + + // test 5: spread props renders + expect($('#component-spread-props').text(), 'Hello world!'); + + // test 6: Can use TS components + expect($('.ts-component')).to.have.lengthOf(1); + + // test 7: Can use Pure components + expect($('#pure')).to.have.lengthOf(1); + + // test 8: Check number of islands + expect($('astro-island[uid]')).to.have.lengthOf(9); + + // test 9: Check island deduplication + const uniqueRootUIDs = new Set($('astro-island').map((i, el) => $(el).attr('uid'))); + expect(uniqueRootUIDs.size).to.equal(8); + + // 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]).find('astro-slot').html() + ); + + // test 11: Should generate unique React.useId per island + const islandsWithId = $('.react-use-id'); + expect(islandsWithId).to.have.lengthOf(2); + expect($(islandsWithId[0]).attr('id')).to.not.equal($(islandsWithId[1]).attr('id')); + }); + + it('Can load Vue', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + expect($('#vue-h2').text()).to.equal('Hasta la vista, baby'); + }); + + it('Can use a pragma comment', async () => { + const html = await fixture.readFile('/pragma-comment/index.html'); + const $ = cheerioLoad(html); + + // test 1: rendered the PragmaComment component + expect($('.pragma-comment')).to.have.lengthOf(2); + }); + + // TODO: is this still a relevant test? + it.skip('Includes reactroot on hydrating components', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const div = $('#research'); + + // test 1: has the hydration attr + expect(div.attr('data-reactroot')).to.be.ok; + + // test 2: renders correctly + expect(div.html()).to.equal('foo bar <!-- -->1'); + }); + + it('Can load Suspense-using components', async () => { + const html = await fixture.readFile('/suspense/index.html'); + const $ = cheerioLoad(html); + expect($('#client #lazy')).to.have.lengthOf(1); + expect($('#server #lazy')).to.have.lengthOf(1); + }); + + it('Can pass through props with cloneElement', async () => { + const html = await fixture.readFile('/index.html'); + 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('../../../astro/test/test-utils.js').Fixture} */ + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('scripts proxy correctly', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerioLoad(html); + + for (const script of $('script').toArray()) { + const { src } = script.attribs; + if (!src) continue; + expect((await fixture.fetch(src)).status, `404: ${src}`).to.equal(200); + } + }); + + // TODO: move this to separate dev test? + it.skip('Throws helpful error message on window SSR', async () => { + const html = await fixture.fetch('/window/index.html'); + expect(html).to.include( + `[/window] + The window object is not available during server-side rendering (SSR). + Try using \`import.meta.env.SSR\` to write SSR-friendly code. + https://docs.astro.build/reference/api-reference/#importmeta` + ); + }); + + // In moving over to Vite, the jsx-runtime import is now obscured. TODO: update the method of finding this. + it.skip('uses the new JSX transform', async () => { + const html = await fixture.fetch('/index.html'); + + // Grab the imports + const exp = /import\("(.+?)"\)/g; + let match, componentUrl; + while ((match = exp.exec(html))) { + if (match[1].includes('Research.js')) { + componentUrl = match[1]; + break; + } + } + const component = await fixture.readFile(componentUrl); + const jsxRuntime = component.imports.filter((i) => i.specifier.includes('jsx-runtime')); + + // test 1: react/jsx-runtime is used for the component + expect(jsxRuntime).to.be.ok; + }); + + it('When a nested component throws it does not crash the server', async () => { + const res = await fixture.fetch('/error-rendering'); + await res.arrayBuffer(); + }); + }); +}); diff --git a/packages/integrations/react/vnode-children.js b/packages/integrations/react/vnode-children.js new file mode 100644 index 000000000..9c7abe644 --- /dev/null +++ b/packages/integrations/react/vnode-children.js @@ -0,0 +1,37 @@ +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; +} |