diff --git a/examples/framework-react/src/components/Counter.tsx b/examples/framework-react/src/components/Counter.tsx index cc416d3f1..40f1e11ef 100644 --- a/examples/framework-react/src/components/Counter.tsx +++ b/examples/framework-react/src/components/Counter.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import './Counter.css'; +import { Context } from './Provider.tsx'; export default function Counter({ children, @@ -11,15 +12,16 @@ export default function Counter({ const [count, setCount] = useState(initialCount); const add = () => setCount((i) => i + 1); const subtract = () => setCount((i) => i - 1); + console.log('Counter', { count }); return ( - <> +
{count}
{children}
- +
); } diff --git a/examples/framework-react/src/components/Display.tsx b/examples/framework-react/src/components/Display.tsx new file mode 100644 index 000000000..43485d919 --- /dev/null +++ b/examples/framework-react/src/components/Display.tsx @@ -0,0 +1,14 @@ +import { useContext } from 'react'; +import { Context } from './Provider.tsx'; + +export default function Display({ children }) { + const { count } = useContext(Context); + console.log('Display', { count }); + + return ( + + ); +} diff --git a/examples/framework-react/src/components/Provider.tsx b/examples/framework-react/src/components/Provider.tsx new file mode 100644 index 000000000..6a8881ba7 --- /dev/null +++ b/examples/framework-react/src/components/Provider.tsx @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const Context = createContext<{ count: number }>({ count: 0 }); diff --git a/examples/framework-react/src/pages/index.astro b/examples/framework-react/src/pages/index.astro index cd7bdc52e..4e54cf689 100644 --- a/examples/framework-react/src/pages/index.astro +++ b/examples/framework-react/src/pages/index.astro @@ -1,8 +1,9 @@ --- // Component Imports import Counter from '../components/Counter'; +import Display from '../components/Display'; const someProps = { - count: 0, + count: 0, }; // Full Astro Component Syntax: @@ -10,27 +11,34 @@ const someProps = { --- - - - - - - - - -
- -

Hello, React!

-
-
- + + + + + + + + +
+ +
+

Hello, React!

+ + + + + +
+
+
+ diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js index d8948e7bb..696a34110 100644 --- a/packages/integrations/react/client.js +++ b/packages/integrations/react/client.js @@ -1,6 +1,8 @@ import { createElement, startTransition } from 'react'; import { createRoot, hydrateRoot } from 'react-dom/client'; import StaticHtml from './static-html.js'; +import { Portal } from './wrapper.js'; +import { Fragment } from 'react'; function isAlreadyHydrated(element) { for (const key in element) { @@ -10,31 +12,58 @@ function isAlreadyHydrated(element) { } } -export default (element) => - (Component, props, { default: children, ...slotted }, { client }) => { +const app = document.createElement('astro-app'); +const root = createRoot(app) +const instances = new Map(); +const instanceChildren = new Map(); +const instanceParents = new Map(); + +function buildTree() { + const vnodes = []; + function addNode(node) { + const parent = instanceParents.get(node); + const children = instanceChildren.get(node); + const self = node({ children: children.map(child => addNode(child) )}); + if (!parent) { + vnodes.push(self); + } else { + return self; + } + } + for (const i of instances.values()) { + addNode(i); + } + return vnodes; +} + +export default (element) => (Component, props, { default: children, ...slotted }, meta) => { if (!element.hasAttribute('ssr')) return; + if (element.parentElement.closest('astro-island[ssr]')) return; + + const parentElement = element.parentElement.closest('astro-island'); + let parentInstance = null; + if (parentElement) parentInstance = instances.get(parentElement); + const renderOptions = { identifierPrefix: element.getAttribute('prefix'), }; for (const [key, value] of Object.entries(slotted)) { props[key] = createElement(StaticHtml, { value, name: key }); } - const componentEl = createElement( + const instance = ({ children: _children = [] }) => createElement(Portal, { host: element }, createElement( Component, props, - children != null ? createElement(StaticHtml, { value: children }) : children - ); - const rootKey = isAlreadyHydrated(element); - // HACK: delete internal react marker for nested components to suppress aggressive warnings - if (rootKey) { - delete element[rootKey]; + children != null ? createElement(StaticHtml, { value: children }) : children, + ..._children + )); + + instances.set(element, instance); + instanceChildren.set(instance, []) + if (parentInstance) { + instanceChildren.set(parentInstance, [...instanceChildren.get(parentInstance), instance]) + instanceParents.set(instance, parentInstance) } - if (client === 'only') { - return startTransition(() => { - createRoot(element).render(componentEl); - }); - } - return startTransition(() => { - hydrateRoot(element, componentEl, renderOptions); - }); - }; + const tree = buildTree(); + element.replaceChildren(); + root.render(createElement(Fragment, {}, ...tree)) + } diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 682811c7c..c33921800 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -27,6 +27,7 @@ "./server-v17.js": "./server-v17.js", "./package.json": "./package.json", "./jsx-runtime": "./jsx-runtime.js", + "./wrapper.js": "./wrapper.js", "./vnode-children.js": "./vnode-children.js" }, "files": [ @@ -38,6 +39,7 @@ "server.js", "server-v17.js", "static-html.js", + "wrapper.js", "vnode-children.js" ], "scripts": { @@ -48,6 +50,7 @@ "dependencies": { "@babel/core": "^7.22.5", "@babel/plugin-transform-react-jsx": "^7.22.5", + "its-fine": "^1.1.1", "ultrahtml": "^1.2.0" }, "devDependencies": { @@ -55,10 +58,10 @@ "@types/react-dom": "^17.0.20", "astro": "workspace:*", "astro-scripts": "workspace:*", - "react": "^18.1.0", - "react-dom": "^18.1.0", "chai": "^4.3.7", "cheerio": "1.0.0-rc.12", + "react": "^18.1.0", + "react-dom": "^18.1.0", "vite": "^4.4.6" }, "peerDependencies": { diff --git a/packages/integrations/react/wrapper.js b/packages/integrations/react/wrapper.js new file mode 100644 index 000000000..e52a2db22 --- /dev/null +++ b/packages/integrations/react/wrapper.js @@ -0,0 +1,5 @@ +import { createPortal } from 'react-dom'; + +export function Portal({ children, host }) { + return createPortal([children], host); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43f0707d0..e691a1c95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4772,6 +4772,9 @@ importers: '@babel/plugin-transform-react-jsx': specifier: ^7.22.5 version: 7.22.5(@babel/core@7.22.5) + its-fine: + specifier: ^1.1.1 + version: 1.1.1(react@18.2.0) ultrahtml: specifier: ^1.2.0 version: 1.2.0 @@ -9117,6 +9120,12 @@ packages: '@types/react': 18.2.13 dev: false + /@types/react-reconciler@0.28.2: + resolution: {integrity: sha512-8tu6lHzEgYPlfDf/J6GOQdIc+gs+S2yAqlby3zTsB3SP2svlqTYe5fwZNtZyfactP74ShooP2vvi1BOp9ZemWw==} + dependencies: + '@types/react': 18.2.13 + dev: false + /@types/react@17.0.62: resolution: {integrity: sha512-eANCyz9DG8p/Vdhr0ZKST8JV12PhH2ACCDYlFw6DIO+D+ca+uP4jtEDEpVqXZrh/uZdXQGwk7whJa3ah5DtyLw==} dependencies: @@ -13105,6 +13114,18 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /its-fine@1.1.1(react@18.2.0): + resolution: {integrity: sha512-v1Ia1xl20KbuSGlwoaGsW0oxsw8Be+TrXweidxD9oT/1lAh6O3K3/GIM95Tt6WCiv6W+h2M7RB1TwdoAjQyyKw==} + peerDependencies: + react: '>=18.0' + peerDependenciesMeta: + react: + optional: true + dependencies: + '@types/react-reconciler': 0.28.2 + react: 18.2.0 + dev: false + /jake@10.8.6: resolution: {integrity: sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA==} engines: {node: '>=10'} @@ -18566,25 +18587,21 @@ packages: file:packages/astro/test/fixtures/css-assets/packages/font-awesome: resolution: {directory: packages/astro/test/fixtures/css-assets/packages/font-awesome, type: directory} name: '@test/astro-font-awesome-package' - version: 0.0.1 dev: false file:packages/astro/test/fixtures/multiple-renderers/renderers/one: resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/one, type: directory} name: '@test/astro-renderer-one' - version: 1.0.0 dev: false file:packages/astro/test/fixtures/multiple-renderers/renderers/two: resolution: {directory: packages/astro/test/fixtures/multiple-renderers/renderers/two, type: directory} name: '@test/astro-renderer-two' - version: 1.0.0 dev: false file:packages/astro/test/fixtures/solid-component/deps/solid-jsx-component: resolution: {directory: packages/astro/test/fixtures/solid-component/deps/solid-jsx-component, type: directory} name: '@test/solid-jsx-component' - version: 0.0.0 dependencies: solid-js: 1.7.6 dev: false