diff options
author | 2025-06-05 14:25:23 +0000 | |
---|---|---|
committer | 2025-06-05 14:25:23 +0000 | |
commit | e586d7d704d475afe3373a1de6ae20d504f79d6d (patch) | |
tree | 7e3fa24807cebd48a86bd40f866d792181191ee9 /packages/integrations/react/src | |
download | astro-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.ts | 104 | ||||
-rw-r--r-- | packages/integrations/react/src/client-v17.ts | 27 | ||||
-rw-r--r-- | packages/integrations/react/src/client.ts | 121 | ||||
-rw-r--r-- | packages/integrations/react/src/context.ts | 26 | ||||
-rw-r--r-- | packages/integrations/react/src/index.ts | 153 | ||||
-rw-r--r-- | packages/integrations/react/src/jsx-runtime.ts | 8 | ||||
-rw-r--r-- | packages/integrations/react/src/server-v17.ts | 81 | ||||
-rw-r--r-- | packages/integrations/react/src/server.ts | 229 | ||||
-rw-r--r-- | packages/integrations/react/src/static-html.ts | 33 | ||||
-rw-r--r-- | packages/integrations/react/src/types.ts | 4 | ||||
-rw-r--r-- | packages/integrations/react/src/version.ts | 31 | ||||
-rw-r--r-- | packages/integrations/react/src/vnode-children.ts | 29 |
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; +} |