aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/react/src
diff options
context:
space:
mode:
authorGravatar github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 2025-06-05 14:25:23 +0000
committerGravatar github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 2025-06-05 14:25:23 +0000
commite586d7d704d475afe3373a1de6ae20d504f79d6d (patch)
tree7e3fa24807cebd48a86bd40f866d792181191ee9 /packages/integrations/react/src
downloadastro-e586d7d704d475afe3373a1de6ae20d504f79d6d.tar.gz
astro-e586d7d704d475afe3373a1de6ae20d504f79d6d.tar.zst
astro-e586d7d704d475afe3373a1de6ae20d504f79d6d.zip
Sync from a8e1c0a7402940e0fc5beef669522b315052df1blatest
Diffstat (limited to 'packages/integrations/react/src')
-rw-r--r--packages/integrations/react/src/actions.ts104
-rw-r--r--packages/integrations/react/src/client-v17.ts27
-rw-r--r--packages/integrations/react/src/client.ts121
-rw-r--r--packages/integrations/react/src/context.ts26
-rw-r--r--packages/integrations/react/src/index.ts153
-rw-r--r--packages/integrations/react/src/jsx-runtime.ts8
-rw-r--r--packages/integrations/react/src/server-v17.ts81
-rw-r--r--packages/integrations/react/src/server.ts229
-rw-r--r--packages/integrations/react/src/static-html.ts33
-rw-r--r--packages/integrations/react/src/types.ts4
-rw-r--r--packages/integrations/react/src/version.ts31
-rw-r--r--packages/integrations/react/src/vnode-children.ts29
12 files changed, 846 insertions, 0 deletions
diff --git a/packages/integrations/react/src/actions.ts b/packages/integrations/react/src/actions.ts
new file mode 100644
index 000000000..06ecd4148
--- /dev/null
+++ b/packages/integrations/react/src/actions.ts
@@ -0,0 +1,104 @@
+import { AstroError } from 'astro/errors';
+
+type FormFn<T> = (formData: FormData) => Promise<T>;
+
+/**
+ * Use an Astro Action with React `useActionState()`.
+ * This function matches your action to the expected types,
+ * and preserves metadata for progressive enhancement.
+ * To read state from your action handler, use {@linkcode experimental_getActionState}.
+ */
+export function experimental_withState<T>(action: FormFn<T>) {
+ // React expects two positional arguments when using `useActionState()`:
+ // 1. The initial state value.
+ // 2. The form data object.
+
+ // Map this first argument to a hidden input
+ // for retrieval from `getActionState()`.
+ const callback = async function (state: T, formData: FormData) {
+ formData.set('_astroActionState', JSON.stringify(state));
+ return action(formData);
+ };
+ if (!('$$FORM_ACTION' in action)) return callback;
+
+ // Re-bind progressive enhancement info for React.
+ callback.$$FORM_ACTION = action.$$FORM_ACTION;
+ // Called by React when form state is passed from the server.
+ // If the action names match, React returns this state from `useActionState()`.
+ callback.$$IS_SIGNATURE_EQUAL = (incomingActionName: string) => {
+ const actionName = new URLSearchParams(action.toString()).get('_action');
+ return actionName === incomingActionName;
+ };
+
+ // React calls `.bind()` internally to pass the initial state value.
+ // Calling `.bind()` seems to remove our `$$FORM_ACTION` metadata,
+ // so we need to define our *own* `.bind()` method to preserve that metadata.
+ Object.defineProperty(callback, 'bind', {
+ value: (...args: Parameters<typeof callback>) =>
+ injectStateIntoFormActionData(callback, ...args),
+ });
+ return callback;
+}
+
+/**
+ * Retrieve the state object from your action handler when using `useActionState()`.
+ * To ensure this state is retrievable, use the {@linkcode experimental_withState} helper.
+ */
+export async function experimental_getActionState<T>({
+ request,
+}: {
+ request: Request;
+}): Promise<T> {
+ const contentType = request.headers.get('Content-Type');
+ if (!contentType || !isFormRequest(contentType)) {
+ throw new AstroError(
+ '`getActionState()` must be called with a form request.',
+ "Ensure your action uses the `accept: 'form'` option.",
+ );
+ }
+ const formData = await request.clone().formData();
+ const state = formData.get('_astroActionState')?.toString();
+ if (!state) {
+ throw new AstroError(
+ '`getActionState()` could not find a state object.',
+ 'Ensure your action was passed to `useActionState()` with the `experimental_withState()` wrapper.',
+ );
+ }
+ return JSON.parse(state) as T;
+}
+
+const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+function isFormRequest(contentType: string) {
+ // Split off parameters like charset or boundary
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
+ const type = contentType.split(';')[0].toLowerCase();
+
+ return formContentTypes.some((t) => type === t);
+}
+
+/**
+ * Override the default `.bind()` method to:
+ * 1. Inject the form state into the form data for progressive enhancement.
+ * 2. Preserve the `$$FORM_ACTION` metadata.
+ */
+function injectStateIntoFormActionData<R extends [this: unknown, state: unknown, ...unknown[]]>(
+ fn: (...args: R) => unknown,
+ ...args: R
+) {
+ const boundFn = Function.prototype.bind.call(fn, ...args);
+ Object.assign(boundFn, fn);
+ const [, state] = args;
+
+ if ('$$FORM_ACTION' in fn && typeof fn.$$FORM_ACTION === 'function') {
+ const metadata = fn.$$FORM_ACTION();
+ boundFn.$$FORM_ACTION = () => {
+ const data = (metadata.data as FormData) ?? new FormData();
+ data.set('_astroActionState', JSON.stringify(state));
+ metadata.data = data;
+
+ return metadata;
+ };
+ }
+ return boundFn;
+}
diff --git a/packages/integrations/react/src/client-v17.ts b/packages/integrations/react/src/client-v17.ts
new file mode 100644
index 000000000..4ba4bcf60
--- /dev/null
+++ b/packages/integrations/react/src/client-v17.ts
@@ -0,0 +1,27 @@
+import { createElement } from 'react';
+import { hydrate, render, unmountComponentAtNode } from 'react-dom';
+import StaticHtml from './static-html.js';
+
+export default (element: HTMLElement) =>
+ (
+ Component: any,
+ props: Record<string, any>,
+ { default: children, ...slotted }: Record<string, any>,
+ { client }: Record<string, string>,
+ ) => {
+ for (const [key, value] of Object.entries(slotted)) {
+ props[key] = createElement(StaticHtml, { value, name: key });
+ }
+ const componentEl = createElement(
+ Component,
+ props,
+ children != null ? createElement(StaticHtml, { value: children }) : children,
+ );
+
+ const isHydrate = client !== 'only';
+ const bootstrap = isHydrate ? hydrate : render;
+ bootstrap(componentEl, element);
+ element.addEventListener('astro:unmount', () => unmountComponentAtNode(element), {
+ once: true,
+ });
+ };
diff --git a/packages/integrations/react/src/client.ts b/packages/integrations/react/src/client.ts
new file mode 100644
index 000000000..ea187a1a1
--- /dev/null
+++ b/packages/integrations/react/src/client.ts
@@ -0,0 +1,121 @@
+import { createElement, startTransition } from 'react';
+import { type Root, createRoot, hydrateRoot } from 'react-dom/client';
+import StaticHtml from './static-html.js';
+
+function isAlreadyHydrated(element: HTMLElement) {
+ for (const key in element) {
+ if (key.startsWith('__reactContainer')) {
+ return key as keyof HTMLElement;
+ }
+ }
+}
+
+function createReactElementFromDOMElement(element: any): any {
+ let attrs: Record<string, string> = {};
+ for (const attr of element.attributes) {
+ attrs[attr.name] = attr.value;
+ }
+ // If the element has no children, we can create a simple React element
+ if (element.firstChild === null) {
+ return createElement(element.localName, attrs);
+ }
+
+ return createElement(
+ element.localName,
+ attrs,
+ Array.from(element.childNodes)
+ .map((c: any) => {
+ if (c.nodeType === Node.TEXT_NODE) {
+ return c.data;
+ } else if (c.nodeType === Node.ELEMENT_NODE) {
+ return createReactElementFromDOMElement(c);
+ } else {
+ return undefined;
+ }
+ })
+ .filter((a) => !!a),
+ );
+}
+
+function getChildren(childString: string, experimentalReactChildren: boolean) {
+ if (experimentalReactChildren && childString) {
+ let children = [];
+ let template = document.createElement('template');
+ template.innerHTML = childString;
+ for (let child of template.content.children) {
+ children.push(createReactElementFromDOMElement(child));
+ }
+ return children;
+ } else if (childString) {
+ return createElement(StaticHtml, { value: childString });
+ } else {
+ return undefined;
+ }
+}
+
+// Keep a map of roots so we can reuse them on re-renders
+let rootMap = new WeakMap<HTMLElement, Root>();
+const getOrCreateRoot = (element: HTMLElement, creator: () => Root) => {
+ let root = rootMap.get(element);
+ if (!root) {
+ root = creator();
+ rootMap.set(element, root);
+ }
+ return root;
+};
+
+export default (element: HTMLElement) =>
+ (
+ Component: any,
+ props: Record<string, any>,
+ { default: children, ...slotted }: Record<string, any>,
+ { client }: Record<string, string>,
+ ) => {
+ if (!element.hasAttribute('ssr')) return;
+
+ const actionKey = element.getAttribute('data-action-key');
+ const actionName = element.getAttribute('data-action-name');
+ const stringifiedActionResult = element.getAttribute('data-action-result');
+
+ const formState =
+ actionKey && actionName && stringifiedActionResult
+ ? [JSON.parse(stringifiedActionResult), actionKey, actionName]
+ : undefined;
+
+ const renderOptions = {
+ identifierPrefix: element.getAttribute('prefix'),
+ formState,
+ };
+ for (const [key, value] of Object.entries(slotted)) {
+ props[key] = createElement(StaticHtml, { value, name: key });
+ }
+
+ const componentEl = createElement(
+ Component,
+ props,
+ getChildren(children, element.hasAttribute('data-react-children')),
+ );
+ const rootKey = isAlreadyHydrated(element);
+ // HACK: delete internal react marker for nested components to suppress aggressive warnings
+ if (rootKey) {
+ delete element[rootKey];
+ }
+ if (client === 'only') {
+ return startTransition(() => {
+ const root = getOrCreateRoot(element, () => {
+ const r = createRoot(element);
+ element.addEventListener('astro:unmount', () => r.unmount(), { once: true });
+ return r;
+ });
+ root.render(componentEl);
+ });
+ }
+ startTransition(() => {
+ const root = getOrCreateRoot(element, () => {
+ const r = hydrateRoot(element, componentEl, renderOptions as any);
+ element.addEventListener('astro:unmount', () => r.unmount(), { once: true });
+ return r;
+ });
+ root.render(componentEl);
+ });
+ };
diff --git a/packages/integrations/react/src/context.ts b/packages/integrations/react/src/context.ts
new file mode 100644
index 000000000..953c35c6a
--- /dev/null
+++ b/packages/integrations/react/src/context.ts
@@ -0,0 +1,26 @@
+import type { SSRResult } from 'astro';
+
+const contexts = new WeakMap<SSRResult, { currentIndex: number; readonly id: string }>();
+
+const ID_PREFIX = 'r';
+
+function getContext(rendererContextResult: SSRResult) {
+ if (contexts.has(rendererContextResult)) {
+ return contexts.get(rendererContextResult);
+ }
+ const ctx = {
+ currentIndex: 0,
+ get id() {
+ return ID_PREFIX + this.currentIndex.toString();
+ },
+ };
+ contexts.set(rendererContextResult, ctx);
+ return ctx;
+}
+
+export function incrementId(rendererContextResult: SSRResult) {
+ const ctx = getContext(rendererContextResult)!;
+ const id = ctx.id;
+ ctx.currentIndex++;
+ return id;
+}
diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts
new file mode 100644
index 000000000..9e781d203
--- /dev/null
+++ b/packages/integrations/react/src/index.ts
@@ -0,0 +1,153 @@
+import react, { type Options as ViteReactPluginOptions } from '@vitejs/plugin-react';
+import type { AstroIntegration, ContainerRenderer } from 'astro';
+import type * as vite from 'vite';
+import {
+ type ReactVersionConfig,
+ type SupportedReactVersion,
+ getReactMajorVersion,
+ isUnsupportedVersion,
+ versionsConfig,
+} from './version.js';
+
+export type ReactIntegrationOptions = Pick<
+ ViteReactPluginOptions,
+ 'include' | 'exclude' | 'babel'
+> & {
+ experimentalReactChildren?: boolean;
+ /**
+ * Disable streaming in React components
+ */
+ experimentalDisableStreaming?: boolean;
+};
+
+const FAST_REFRESH_PREAMBLE = react.preambleCode;
+
+function getRenderer(reactConfig: ReactVersionConfig) {
+ return {
+ name: '@astrojs/react',
+ clientEntrypoint: reactConfig.client,
+ serverEntrypoint: reactConfig.server,
+ };
+}
+
+function optionsPlugin({
+ experimentalReactChildren = false,
+ experimentalDisableStreaming = false,
+}: {
+ experimentalReactChildren: boolean;
+ experimentalDisableStreaming: 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)},
+ experimentalDisableStreaming: ${JSON.stringify(experimentalDisableStreaming)}
+ }`,
+ };
+ }
+ },
+ };
+}
+
+function getViteConfiguration(
+ {
+ include,
+ exclude,
+ babel,
+ experimentalReactChildren,
+ experimentalDisableStreaming,
+ }: ReactIntegrationOptions = {},
+ reactConfig: ReactVersionConfig,
+) {
+ return {
+ optimizeDeps: {
+ include: [reactConfig.client],
+ exclude: [reactConfig.server],
+ },
+ plugins: [
+ react({ include, exclude, babel }),
+ optionsPlugin({
+ experimentalReactChildren: !!experimentalReactChildren,
+ experimentalDisableStreaming: !!experimentalDisableStreaming,
+ }),
+ ],
+ ssr: {
+ noExternal: [
+ // These are all needed to get mui to work.
+ '@mui/material',
+ '@mui/base',
+ '@babel/runtime',
+ 'use-immer',
+ '@material-tailwind/react',
+ ],
+ },
+ };
+}
+
+export default function ({
+ include,
+ exclude,
+ babel,
+ experimentalReactChildren,
+ experimentalDisableStreaming,
+}: ReactIntegrationOptions = {}): AstroIntegration {
+ const majorVersion = getReactMajorVersion();
+ if (isUnsupportedVersion(majorVersion)) {
+ throw new Error(`Unsupported React version: ${majorVersion}.`);
+ }
+ const versionConfig = versionsConfig[majorVersion as SupportedReactVersion];
+
+ return {
+ name: '@astrojs/react',
+ hooks: {
+ 'astro:config:setup': ({ command, addRenderer, updateConfig, injectScript }) => {
+ addRenderer(getRenderer(versionConfig));
+ updateConfig({
+ vite: getViteConfiguration(
+ { include, exclude, babel, experimentalReactChildren, experimentalDisableStreaming },
+ versionConfig,
+ ),
+ });
+ if (command === 'dev') {
+ const preamble = FAST_REFRESH_PREAMBLE.replace(`__BASE__`, '/');
+ injectScript('before-hydration', preamble);
+ }
+ },
+ 'astro:config:done': ({ logger, config }) => {
+ const knownJsxRenderers = ['@astrojs/react', '@astrojs/preact', '@astrojs/solid-js'];
+ const enabledKnownJsxRenderers = config.integrations.filter((renderer) =>
+ knownJsxRenderers.includes(renderer.name),
+ );
+
+ if (enabledKnownJsxRenderers.length > 1 && !include && !exclude) {
+ logger.warn(
+ 'More than one JSX renderer is enabled. This will lead to unexpected behavior unless you set the `include` or `exclude` option. See https://docs.astro.build/en/guides/integrations-guide/react/#combining-multiple-jsx-frameworks for more information.',
+ );
+ }
+ },
+ },
+ };
+}
+
+export function getContainerRenderer(): ContainerRenderer {
+ const majorVersion = getReactMajorVersion();
+ if (isUnsupportedVersion(majorVersion)) {
+ throw new Error(`Unsupported React version: ${majorVersion}.`);
+ }
+ const versionConfig = versionsConfig[majorVersion as SupportedReactVersion];
+
+ return {
+ name: '@astrojs/react',
+ serverEntrypoint: versionConfig.server,
+ };
+}
diff --git a/packages/integrations/react/src/jsx-runtime.ts b/packages/integrations/react/src/jsx-runtime.ts
new file mode 100644
index 000000000..3f0e51c65
--- /dev/null
+++ b/packages/integrations/react/src/jsx-runtime.ts
@@ -0,0 +1,8 @@
+// This module is a simple wrapper around react/jsx-runtime so that
+// it can run in Node ESM. 'react' doesn't declare this module as an export map
+// So we have to use the .js. The .js is not added via the babel automatic JSX transform
+// hence this module as a workaround.
+import jsxr from 'react/jsx-runtime';
+const { jsx, jsxs, Fragment } = jsxr;
+
+export { jsx, jsxs, Fragment };
diff --git a/packages/integrations/react/src/server-v17.ts b/packages/integrations/react/src/server-v17.ts
new file mode 100644
index 000000000..92fe21943
--- /dev/null
+++ b/packages/integrations/react/src/server-v17.ts
@@ -0,0 +1,81 @@
+import type { AstroComponentMetadata, NamedSSRLoadedRendererValue } from 'astro';
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import StaticHtml from './static-html.js';
+
+const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
+const reactTypeof = Symbol.for('react.element');
+
+function check(Component: any, props: Record<string, any>, children: any) {
+ // Note: there are packages that do some unholy things to create "components".
+ // Checking the $$typeof property catches most of these patterns.
+ if (typeof Component === 'object') {
+ return Component['$$typeof']?.toString().slice('Symbol('.length).startsWith('react');
+ }
+ if (typeof Component !== 'function') return false;
+ if (Component.name === 'QwikComponent') return false;
+
+ if (Component.prototype != null && typeof Component.prototype.render === 'function') {
+ return React.Component.isPrototypeOf(Component) || React.PureComponent.isPrototypeOf(Component);
+ }
+
+ let isReactComponent = false;
+ function Tester(...args: Array<any>) {
+ try {
+ const vnode = Component(...args);
+ if (vnode && vnode['$$typeof'] === reactTypeof) {
+ isReactComponent = true;
+ }
+ } catch {}
+
+ return React.createElement('div');
+ }
+
+ renderToStaticMarkup(Tester, props, children, {} as any);
+
+ return isReactComponent;
+}
+
+async function renderToStaticMarkup(
+ Component: any,
+ props: Record<string, any>,
+ { default: children, ...slotted }: Record<string, any>,
+ metadata?: AstroComponentMetadata,
+) {
+ delete props['class'];
+ const slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(slotted)) {
+ const name = slotName(key);
+ slots[name] = React.createElement(StaticHtml, { value, name });
+ }
+ // Note: create newProps to avoid mutating `props` before they are serialized
+ const newProps = {
+ ...props,
+ ...slots,
+ };
+ const newChildren = children ?? props.children;
+ if (newChildren != null) {
+ newProps.children = React.createElement(StaticHtml, {
+ // Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
+ hydrate: metadata?.astroStaticSlot ? !!metadata.hydrate : true,
+ value: newChildren,
+ });
+ }
+ const vnode = React.createElement(Component, newProps);
+ let html: string;
+ if (metadata?.hydrate) {
+ html = ReactDOM.renderToString(vnode);
+ } else {
+ html = ReactDOM.renderToStaticMarkup(vnode);
+ }
+ return { html };
+}
+
+const renderer: NamedSSRLoadedRendererValue = {
+ name: '@astrojs/react',
+ check,
+ renderToStaticMarkup,
+ supportsAstroStaticSlot: true,
+};
+
+export default renderer;
diff --git a/packages/integrations/react/src/server.ts b/packages/integrations/react/src/server.ts
new file mode 100644
index 000000000..f7e273e6b
--- /dev/null
+++ b/packages/integrations/react/src/server.ts
@@ -0,0 +1,229 @@
+import opts from 'astro:react:opts';
+import type { AstroComponentMetadata, NamedSSRLoadedRendererValue } from 'astro';
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import { incrementId } from './context.js';
+import StaticHtml from './static-html.js';
+import type { RendererContext } from './types.js';
+
+const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
+const reactTypeof = Symbol.for('react.element');
+const reactTransitionalTypeof = Symbol.for('react.transitional.element');
+
+async function check(
+ this: RendererContext,
+ Component: any,
+ props: Record<string, any>,
+ children: any,
+) {
+ // Note: there are packages that do some unholy things to create "components".
+ // Checking the $$typeof property catches most of these patterns.
+ if (typeof Component === 'object') {
+ return Component['$$typeof'].toString().slice('Symbol('.length).startsWith('react');
+ }
+ if (typeof Component !== 'function') return false;
+ if (Component.name === 'QwikComponent') return false;
+
+ // Preact forwarded-ref components can be functions, which React does not support
+ if (typeof Component === 'function' && Component['$$typeof'] === Symbol.for('react.forward_ref'))
+ return false;
+
+ if (Component.prototype != null && typeof Component.prototype.render === 'function') {
+ return React.Component.isPrototypeOf(Component) || React.PureComponent.isPrototypeOf(Component);
+ }
+
+ let isReactComponent = false;
+ function Tester(...args: Array<any>) {
+ try {
+ const vnode = Component(...args);
+ if (
+ vnode &&
+ (vnode['$$typeof'] === reactTypeof || vnode['$$typeof'] === reactTransitionalTypeof)
+ ) {
+ isReactComponent = true;
+ }
+ } catch {}
+
+ return React.createElement('div');
+ }
+
+ await renderToStaticMarkup.call(this, Tester, props, children);
+
+ return isReactComponent;
+}
+
+async function getNodeWritable(): Promise<typeof import('node:stream').Writable> {
+ let nodeStreamBuiltinModuleName = 'node:stream';
+ let { Writable } = await import(/* @vite-ignore */ nodeStreamBuiltinModuleName);
+ return Writable;
+}
+
+function needsHydration(metadata?: AstroComponentMetadata) {
+ // Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
+ return metadata?.astroStaticSlot ? !!metadata.hydrate : true;
+}
+
+async function renderToStaticMarkup(
+ this: RendererContext,
+ Component: any,
+ props: Record<string, any>,
+ { default: children, ...slotted }: Record<string, any>,
+ metadata?: AstroComponentMetadata,
+) {
+ let prefix;
+ if (this && this.result) {
+ prefix = incrementId(this.result);
+ }
+ const attrs: Record<string, any> = { prefix };
+
+ delete props['class'];
+ const slots: Record<string, any> = {};
+ for (const [key, value] of Object.entries(slotted)) {
+ const name = slotName(key);
+ slots[name] = React.createElement(StaticHtml, {
+ hydrate: needsHydration(metadata),
+ value,
+ name,
+ });
+ }
+ // Note: create newProps to avoid mutating `props` before they are serialized
+ const newProps = {
+ ...props,
+ ...slots,
+ };
+ const newChildren = children ?? props.children;
+ if (children && opts.experimentalReactChildren) {
+ attrs['data-react-children'] = true;
+ 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,
+ });
+ }
+ const formState = this ? await getFormState(this) : undefined;
+ if (formState) {
+ attrs['data-action-result'] = JSON.stringify(formState[0]);
+ attrs['data-action-key'] = formState[1];
+ attrs['data-action-name'] = formState[2];
+ }
+ const vnode = React.createElement(Component, newProps);
+ const renderOptions = {
+ identifierPrefix: prefix,
+ formState,
+ };
+ let html: string;
+ if (opts.experimentalDisableStreaming) {
+ html = ReactDOM.renderToString(vnode);
+ } else if ('renderToReadableStream' in ReactDOM) {
+ html = await renderToReadableStreamAsync(vnode, renderOptions);
+ } else {
+ html = await renderToPipeableStreamAsync(vnode, renderOptions);
+ }
+ return { html, attrs };
+}
+
+async function getFormState({
+ result,
+}: RendererContext): Promise<
+ [actionResult: any, actionKey: string, actionName: string] | undefined
+> {
+ const { request, actionResult } = result;
+
+ if (!actionResult) return undefined;
+ if (!isFormRequest(request.headers.get('content-type'))) return undefined;
+
+ const { searchParams } = new URL(request.url);
+ const formData = await request.clone().formData();
+ /**
+ * The key generated by React to identify each `useActionState()` call.
+ * @example "k511f74df5a35d32e7cf266450d85cb6c"
+ */
+ const actionKey = formData.get('$ACTION_KEY')?.toString();
+ /**
+ * The action name returned by an action's `toString()` property.
+ * This matches the endpoint path.
+ * @example "/_actions/blog.like"
+ */
+ const actionName = searchParams.get('_action');
+
+ if (!actionKey || !actionName) return undefined;
+
+ return [actionResult, actionKey, actionName];
+}
+
+async function renderToPipeableStreamAsync(vnode: any, options: Record<string, any>) {
+ const Writable = await getNodeWritable();
+ let html = '';
+ return new Promise<string>((resolve, reject) => {
+ let error = undefined;
+ let stream = ReactDOM.renderToPipeableStream(vnode, {
+ ...options,
+ onError(err) {
+ error = err;
+ reject(error);
+ },
+ onAllReady() {
+ stream.pipe(
+ new Writable({
+ write(chunk, _encoding, callback) {
+ html += chunk.toString('utf-8');
+ callback();
+ },
+ destroy() {
+ resolve(html);
+ },
+ }),
+ );
+ },
+ });
+ });
+}
+
+/**
+ * Use a while loop instead of "for await" due to cloudflare and Vercel Edge issues
+ * See https://github.com/facebook/react/issues/24169
+ */
+async function readResult(stream: ReactDOM.ReactDOMServerReadableStream) {
+ const reader = stream.getReader();
+ let result = '';
+ const decoder = new TextDecoder('utf-8');
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ if (value) {
+ result += decoder.decode(value);
+ } else {
+ // This closes the decoder
+ decoder.decode(new Uint8Array());
+ }
+
+ return result;
+ }
+ result += decoder.decode(value, { stream: true });
+ }
+}
+
+async function renderToReadableStreamAsync(vnode: any, options: Record<string, any>) {
+ return await readResult(await ReactDOM.renderToReadableStream(vnode, options));
+}
+
+const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
+
+function isFormRequest(contentType: string | null) {
+ // Split off parameters like charset or boundary
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
+ const type = contentType?.split(';')[0].toLowerCase();
+
+ return formContentTypes.some((t) => type === t);
+}
+
+const renderer: NamedSSRLoadedRendererValue = {
+ name: '@astrojs/react',
+ check,
+ renderToStaticMarkup,
+ supportsAstroStaticSlot: true,
+};
+
+export default renderer;
diff --git a/packages/integrations/react/src/static-html.ts b/packages/integrations/react/src/static-html.ts
new file mode 100644
index 000000000..010896196
--- /dev/null
+++ b/packages/integrations/react/src/static-html.ts
@@ -0,0 +1,33 @@
+import { createElement as h } from 'react';
+
+/**
+ * Astro passes `children` as a string of HTML, so we need
+ * a wrapper `div` to render that content as VNodes.
+ *
+ * As a bonus, we can signal to React that this subtree is
+ * entirely static and will never change via `shouldComponentUpdate`.
+ */
+const StaticHtml = ({
+ value,
+ name,
+ hydrate = true,
+}: { value: string | null; name?: string; hydrate?: boolean }) => {
+ if (!value) return null;
+ const tagName = hydrate ? 'astro-slot' : 'astro-static-slot';
+ return h(tagName, {
+ name,
+ suppressHydrationWarning: true,
+ dangerouslySetInnerHTML: { __html: value },
+ });
+};
+
+/**
+ * This tells React to opt-out of re-rendering this subtree,
+ * In addition to being a performance optimization,
+ * this also allows other frameworks to attach to `children`.
+ *
+ * See https://preactjs.com/guide/v8/external-dom-mutations
+ */
+StaticHtml.shouldComponentUpdate = () => false;
+
+export default StaticHtml;
diff --git a/packages/integrations/react/src/types.ts b/packages/integrations/react/src/types.ts
new file mode 100644
index 000000000..5dff5b0b4
--- /dev/null
+++ b/packages/integrations/react/src/types.ts
@@ -0,0 +1,4 @@
+import type { SSRResult } from 'astro';
+export type RendererContext = {
+ result: SSRResult;
+};
diff --git a/packages/integrations/react/src/version.ts b/packages/integrations/react/src/version.ts
new file mode 100644
index 000000000..29118f13b
--- /dev/null
+++ b/packages/integrations/react/src/version.ts
@@ -0,0 +1,31 @@
+import { version as ReactVersion } from 'react-dom';
+
+export type SupportedReactVersion = keyof typeof versionsConfig;
+export type ReactVersionConfig = (typeof versionsConfig)[SupportedReactVersion];
+
+export function getReactMajorVersion(): number {
+ const matches = /\d+\./.exec(ReactVersion);
+ if (!matches) {
+ return NaN;
+ }
+ return Number(matches[0]);
+}
+
+export function isUnsupportedVersion(majorVersion: number) {
+ return majorVersion < 17 || majorVersion > 19 || Number.isNaN(majorVersion);
+}
+
+export const versionsConfig = {
+ 17: {
+ server: '@astrojs/react/server-v17.js',
+ client: '@astrojs/react/client-v17.js',
+ },
+ 18: {
+ server: '@astrojs/react/server.js',
+ client: '@astrojs/react/client.js',
+ },
+ 19: {
+ server: '@astrojs/react/server.js',
+ client: '@astrojs/react/client.js',
+ },
+};
diff --git a/packages/integrations/react/src/vnode-children.ts b/packages/integrations/react/src/vnode-children.ts
new file mode 100644
index 000000000..6aa9724c6
--- /dev/null
+++ b/packages/integrations/react/src/vnode-children.ts
@@ -0,0 +1,29 @@
+import { Fragment, createElement } from 'react';
+import { DOCUMENT_NODE, ELEMENT_NODE, TEXT_NODE, parse } from 'ultrahtml';
+
+let ids = 0;
+export default function convert(children: any) {
+ let doc = parse(children.toString().trim());
+ let id = ids++;
+ let key = 0;
+
+ function createReactElementFromNode(node: any) {
+ const childVnodes =
+ Array.isArray(node.children) && node.children.length
+ ? node.children.map((child: any) => createReactElementFromNode(child)).filter(Boolean)
+ : undefined;
+
+ if (node.type === DOCUMENT_NODE) {
+ return createElement(Fragment, {}, childVnodes);
+ } else if (node.type === ELEMENT_NODE) {
+ const { class: className, ...props } = node.attributes;
+ return createElement(node.name, { ...props, className, key: `${id}-${key++}` }, childVnodes);
+ } else if (node.type === TEXT_NODE) {
+ // 0-length text gets omitted in JSX
+ return node.value.trim() ? node.value : undefined;
+ }
+ }
+
+ const root = createReactElementFromNode(doc);
+ return root.props.children;
+}