diff options
Diffstat (limited to 'packages/integrations/react/server.js')
-rw-r--r-- | packages/integrations/react/server.js | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js new file mode 100644 index 000000000..67d9e9386 --- /dev/null +++ b/packages/integrations/react/server.js @@ -0,0 +1,213 @@ +import opts from 'astro:react:opts'; +import React from 'react'; +import ReactDOM from 'react-dom/server'; +import { incrementId } from './context.js'; +import StaticHtml from './static-html.js'; + +const slotName = (str) => 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(Component, props, children) { + // 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) { + try { + const vnode = Component(...args); + if ( + vnode && + (vnode['$$typeof'] === reactTypeof || vnode['$$typeof'] === reactTransitionalTypeof) + ) { + isReactComponent = true; + } + } catch {} + + return React.createElement('div'); + } + + await renderToStaticMarkup(Tester, props, children, {}); + + return isReactComponent; +} + +async function getNodeWritable() { + let nodeStreamBuiltinModuleName = 'node:stream'; + let { Writable } = await import(/* @vite-ignore */ nodeStreamBuiltinModuleName); + return Writable; +} + +function needsHydration(metadata) { + // Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot` + return metadata.astroStaticSlot ? !!metadata.hydrate : true; +} + +async function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) { + let prefix; + if (this && this.result) { + prefix = incrementId(this.result); + } + const attrs = { prefix }; + + delete props['class']; + const slots = {}; + 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; + 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 }; +} + +/** + * @returns {Promise<[actionResult: any, actionKey: string, actionName: string] | undefined>} + */ +async function getFormState({ result }) { + 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, options) { + const Writable = await getNodeWritable(); + let html = ''; + return new Promise((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) { + 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, options) { + return await readResult(await ReactDOM.renderToReadableStream(vnode, options)); +} + +const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data']; + +function isFormRequest(contentType) { + // 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); +} + +export default { + name: '@astrojs/react', + check, + renderToStaticMarkup, + supportsAstroStaticSlot: true, +}; |