import type { ComponentHandle, FrameHandle, Key, RemixNode } from '../runtime/component.ts' import type { ElementType, ElementProps, RemixElement } from '../runtime/jsx.ts' import { Fragment, createComponent, createFrameHandle, Frame } from '../runtime/component.ts' import { isEntry, type EntryComponent } from '../runtime/client-entries.ts' import { FRAMEWORK_PROPS as RUNTIME_FRAMEWORK_PROPS, SELF_CLOSING_TAGS, normalizeAttributeName, serializeStyleObject, shouldStringifyBooleanAttribute, } from '../runtime/core/attributes.ts' import { appendFlushMarker, type FlushKind, stripFlushMarkers } from '../runtime/stream-protocol.ts' import { REMIX_UI_STYLE_LAYER } from '../style/layers.ts' interface VNode { type: ElementType props: ElementProps key?: Key _handle?: ComponentHandle _parent?: VNode } export function createVNode(type: ElementType, props: ElementProps, key?: Key): VNode { return { type, props, key } } /** * Options for server-side rendering to a byte stream. */ export interface RenderToStreamOptions { /** Source URL to associate with the current frame render. */ frameSrc?: string | URL /** Source URL for the top-level frame in nested frame renders. */ topFrameSrc?: string | URL /** Signal that cancels pending server rendering work. */ signal?: AbortSignal /** Error hook invoked when rendering work throws. */ onError?: (error: unknown) => void /** Callback used to resolve nested frame content during streaming SSR. */ resolveFrame?: ( src: string, target?: string, context?: ResolveFrameContext, ) => Promise> | string | ReadableStream /** * Callback used to resolve runtime module metadata for client entry modules during SSR. */ resolveClientEntry?: ( entryId: string, component: EntryComponent, ) => Promise | ResolvedClientEntry } /** * Context passed to `resolveFrame` during server rendering. */ export interface ResolveFrameContext { /** Source URL for the frame currently being resolved. */ currentFrameSrc: string /** Source URL for the top-level frame in the current render. */ topFrameSrc: string } interface HydrationData { moduleUrl: string exportName: string props: Record } interface UnresolvedHydrationData { entryId: string component: EntryComponent props: Record } interface ResolvedClientEntry { href: string exportName: string } interface FrameData { status: 'pending' | 'resolved' name?: string src: string } interface RenderContext { insideSvg: boolean onError: (error: unknown) => void parentVNode?: VNode styleCache: Map emittedStyles: Set resolveFrame: ( src: string, target?: string, context?: ResolveFrameContext, ) => Promise> | string | ReadableStream pendingFrames: Array<{ frameId: string; promise: Promise }> hydrationData: Map unresolvedHydrationData: Map frameData: Map blockingFrameTails: ReadableStream[] signal: AbortSignal flushKind: FlushKind serverIdScope: string serverIdCounter: number } interface ResolvedFrameHtml { html: string tail?: ReadableStream } interface SsrFrameState { frame: FrameHandle topFrame: FrameHandle } type Segment = | { kind: 'static'; html: string } | { kind: 'composite'; parts: Segment[] } | { kind: 'frame' frameId: string content: Segment | null pending?: Promise } const TEXTAREA_VALUE_PROPS = new Set(['value', 'defaultValue']) const INPUT_DEFAULT_PROPS = new Set(['defaultValue', 'defaultChecked']) const DOCTYPE_PATTERN = /]*)?>/gi function stripDoctypeMarkup(html: string): string { return html.replace(DOCTYPE_PATTERN, '') } function hasRenderableHtml(html: string): boolean { return stripDoctypeMarkup(html).trim() !== '' } function emptyReadableStream(): ReadableStream { return new ReadableStream({ start(controller) { controller.close() }, }) } function getStyleLayerName(selector: string, layer: string = REMIX_UI_STYLE_LAYER): string { return `${layer}.${selector}` } const SSR_OMITTED_PROPS = RUNTIME_FRAMEWORK_PROPS const ssrSignal = Object.freeze({ get aborted() { return false }, get reason() { return undefined }, get onabort() { return null }, set onabort(_: AbortSignal['onabort']) {}, addEventListener( _type: string, _listener: EventListenerOrEventListenerObject | null, _options?: AddEventListenerOptions | boolean, ) {}, removeEventListener( _type: string, _listener: EventListenerOrEventListenerObject | null, _options?: EventListenerOptions | boolean, ) {}, dispatchEvent(_event: Event) { return true }, throwIfAborted() {}, }) as AbortSignal /** * Renders a node tree to a streaming HTML response body. * * @param node Node tree to render. * @param options Stream rendering options. * @returns A readable byte stream of HTML. */ export function renderToStream( node: RemixNode, options?: RenderToStreamOptions, ): ReadableStream { let encoder = new TextEncoder() let onError = options?.onError ?? ((error) => console.error(error)) let currentFrameSrc = normalizeFrameSrc(options?.frameSrc ?? options?.topFrameSrc) let topFrameSrc = normalizeFrameSrc(options?.topFrameSrc ?? currentFrameSrc) let rootFrameState = createSsrFrameState(currentFrameSrc, topFrameSrc) let renderAbortController = new AbortController() let context: RenderContext = { insideSvg: false, onError, resolveFrame: options?.resolveFrame ?? defaultResolveFrame, styleCache: new Map(), emittedStyles: new Set(), pendingFrames: [], hydrationData: new Map(), unresolvedHydrationData: new Map(), frameData: new Map(), blockingFrameTails: [], signal: renderAbortController.signal, flushKind: 'fragment', serverIdScope: crypto.randomUUID().slice(0, 8), serverIdCounter: 0, } function cancel(reason: unknown): void { if (!renderAbortController.signal.aborted) { renderAbortController.abort(reason) } } let signal = options?.signal if (signal?.aborted) { cancel(signal.reason) } else { signal?.addEventListener('abort', () => cancel(signal.reason), { once: true }) } return new ReadableStream({ async start(controller) { try { let root = buildSegment(node, context, rootFrameState) await resolveBlocking(root) if (closeIfCancelled(controller, context)) return await resolveClientEntries(context, options?.resolveClientEntry) if (closeIfCancelled(controller, context)) return validateClientEntriesForHydration(context) let html = serializeSegment(root) let finalHtml = finalizeHtml(html, context) let bytes = encoder.encode(appendFlushMarker(finalHtml, context.flushKind)) if (closeIfCancelled(controller, context)) return controller.enqueue(bytes) // If we have any tails from blocking frame streams, stream them now. // These contain nested non-blocking frame templates (or other follow-up chunks) // that must come after the initial document chunk. let tailPromise = context.blockingFrameTails.length > 0 ? streamByteStreams(context.blockingFrameTails, controller, context) : Promise.resolve() // If we have pending non-blocking frames, stream them as they resolve let pendingPromise = context.pendingFrames.length > 0 ? streamPendingFrames(context, controller, encoder) : Promise.resolve() await Promise.all([tailPromise, pendingPromise]) if (closeIfCancelled(controller, context)) return controller.close() } catch (error) { if (isSignalAbortError(context.signal, error)) { closeStream(controller) return } onError(error) controller.error(error) } }, cancel(reason) { cancel(reason) }, }) } function isSignalAbortError(signal: AbortSignal, error: unknown): boolean { return signal.aborted && error === signal.reason } function closeIfCancelled( controller: ReadableStreamDefaultController, context: RenderContext, ): boolean { if (!context.signal.aborted) return false closeStream(controller) return true } function closeStream(controller: ReadableStreamDefaultController): void { try { controller.close() } catch { // The consumer may already have cancelled the stream. } } function defaultResolveFrame(): never { throw new Error('No resolveFrame provided') } function normalizeFrameSrc(value?: string | URL): string { return value == null ? '' : String(value) } function createSsrFrameState(frameSrc: string, topFrameSrc = frameSrc): SsrFrameState { let topFrame = createFrameHandle({ src: topFrameSrc }) let frame = frameSrc === topFrameSrc ? topFrame : createFrameHandle({ src: frameSrc }) return { frame, topFrame } } function getResolveFrameContext(frameState: SsrFrameState): ResolveFrameContext { return { currentFrameSrc: frameState.frame.src, topFrameSrc: frameState.topFrame.src, } } function randomId(prefix: string): string { return prefix + crypto.randomUUID().slice(0, 8) } function createServerComponentId(context: RenderContext): string { context.serverIdCounter++ return `s${context.serverIdScope}-${context.serverIdCounter}` } async function splitFirstChunk(stream: ReadableStream): Promise { let reader = stream.getReader() let decoder = new TextDecoder() let first: Uint8Array | undefined while (true) { let { value, done } = await reader.read() if (done || !value) break let text = decoder.decode(value, { stream: true }) if (hasRenderableHtml(text)) { first = value break } } if (!first) { decoder.decode() reader.releaseLock() return { html: '', tail: emptyReadableStream() } } let released = false function release() { if (released) return released = true try { reader.releaseLock() } catch { // ignore } } let tail = new ReadableStream({ async pull(controller) { let next = await reader.read() if (next.done) { controller.close() release() return } controller.enqueue(next.value) }, cancel(reason) { release() return reader.cancel(reason) }, }) return { html: stripFlushMarkers(stripDoctypeMarkup(decoder.decode(first))), tail } } async function resolveFrameHtml( input: string | ReadableStream, ): Promise { if (typeof input === 'string') return { html: stripFlushMarkers(stripDoctypeMarkup(input)) } return await splitFirstChunk(input) } function isRemixElement(node: unknown): node is RemixElement { return typeof node === 'object' && node !== null && '$rmx' in node } function staticSeg(html: string): Segment { return { kind: 'static', html } } function compositeSeg(parts: Segment[]): Segment { return { kind: 'composite', parts } } function buildSegment(node: RemixNode, context: RenderContext, frameState: SsrFrameState): Segment { if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') { return staticSeg(escapeTextContent(String(node))) } if (node === null || node === undefined || typeof node === 'boolean') { return staticSeg('') } if (Array.isArray(node)) { return compositeSeg(node.map((child) => buildSegment(child, context, frameState))) } if (isRemixElement(node)) { let type = node.type let props = node.props if (type === Fragment) { let children = props.children return children != null ? buildSegment(children, context, frameState) : staticSeg('') } if (typeof type === 'string') { let tag = type if (tag === 'html') { context.flushKind = 'document' return buildElementSegment(tag, props, context, frameState) } if (tag === 'head') { return buildHeadElementSegment(tag, props, context, frameState) } return buildElementSegment(tag, props, context, frameState) } if (typeof type === 'function') { if (type === Frame) { return buildFrameSegment(node, context, frameState) } if (isEntry(type)) { return buildEntrySegment(type, props, context, frameState) } return buildComponentSegment( type, props, context, createServerComponentId(context), frameState, ) } } return staticSeg('') } function buildFrameSegment( node: RemixElement, context: RenderContext, frameState: SsrFrameState, ): Segment { let props = node.props let frameId = randomId('f') // Store frame data in context for aggregation context.frameData.set(frameId, { status: props.fallback ? 'pending' : 'resolved', name: props.name, src: props.src, }) let seg: Segment = { kind: 'frame', frameId, content: null, } let resolveFrameContext = getResolveFrameContext(frameState) let nonBlocking = !!props.fallback if (nonBlocking) { seg.content = buildSegment(props.fallback, context, frameState) let framePromise = Promise.resolve( context.resolveFrame(props.src, props.name, resolveFrameContext), ).then(async (resolved) => resolveFrameHtml(resolved)) // The response stream can be cancelled before pending frames are drained. // Keep the promise observed so request aborts don't become unhandled. framePromise.catch(() => {}) context.pendingFrames.push({ frameId, promise: framePromise }) } else { seg.pending = Promise.resolve( context.resolveFrame(props.src, props.name, resolveFrameContext), ).then(async (resolved) => { let { html, tail } = await resolveFrameHtml(resolved) seg.content = staticSeg(html) if (tail) { context.blockingFrameTails.push(tail) } }) } return seg } function buildElementSegment( tag: string, props: any, context: RenderContext, frameState: SsrFrameState, ): Segment { let mixedProps = resolveSsrMixedProps(tag, props, context, frameState) let processedProps = processStyleProps(mixedProps) // Determine namespace context for the current element and its children let currentIsSvg = context.insideSvg || tag === 'svg' if (!currentIsSvg && tag === 'textarea') { return buildTextareaElementSegment(tag, processedProps) } let attrs = !currentIsSvg && tag === 'input' ? renderInputAttributes(processedProps) : renderAttributes(processedProps, currentIsSvg) if (SELF_CLOSING_TAGS.has(tag)) { return staticSeg(`<${tag}${attrs} />`) } if (props.innerHTML) { return staticSeg(`<${tag}${attrs}>${props.innerHTML}`) } let open = staticSeg(`<${tag}${attrs}>`) // Adjust svg context for children: foreignObject switches back to HTML let previousInsideSvg = context.insideSvg context.insideSvg = tag === 'foreignObject' ? false : currentIsSvg let children = props.children != null ? buildSegment(props.children, context, frameState) : staticSeg('') context.insideSvg = previousInsideSvg let close = staticSeg(``) return compositeSeg([open, children, close]) } function buildTextareaElementSegment(tag: string, props: any): Segment { let attrs = renderAttributes(props, false, TEXTAREA_VALUE_PROPS) let value = props.value ?? props.defaultValue ?? '' return staticSeg(`<${tag}${attrs}>${escapeTextContent(String(value))}`) } function renderInputAttributes(props: any): string { let value = props.value === undefined && props.defaultValue !== undefined ? props.defaultValue : props.value let checked = props.checked === undefined && props.defaultChecked !== undefined ? props.defaultChecked : props.checked let inputProps = { ...props, ...(value === undefined ? {} : { value }), ...(checked === undefined ? {} : { checked }), } return renderAttributes(inputProps, false, INPUT_DEFAULT_PROPS) } function buildHeadElementSegment( tag: string, props: any, context: RenderContext, frameState: SsrFrameState, ): Segment { let processedProps = processStyleProps(props) let attrs = renderAttributes(processedProps, false) let open = staticSeg(`<${tag}${attrs}>`) let children = props.children != null ? buildSegment(props.children, context, frameState) : staticSeg('') let close = staticSeg(``) return compositeSeg([open, children, close]) } function renderAttributes(props: any, isSvg: boolean, excludedProps?: Set): string { let attrs = '' for (let key in props) { if (SSR_OMITTED_PROPS.has(key)) continue if (excludedProps?.has(key)) continue let value = props[key] let attrName = transformAttributeName(key, isSvg) let shouldStringifyBoolean = shouldStringifyBooleanAttribute(attrName) if (value === undefined || value === null || (value === false && !shouldStringifyBoolean)) { continue } if (typeof value === 'boolean' && shouldStringifyBoolean) { attrs += ` ${attrName}="${escapeHtml(String(value))}"` } else if (value === true) { attrs += ` ${attrName}` } else { attrs += ` ${attrName}="${escapeHtml(String(value))}"` } } return attrs } function resolveSsrMixedProps( hostType: string, initialProps: ElementProps, context: RenderContext, frameState: SsrFrameState, ): ElementProps { let descriptors = resolveSsrMixDescriptors(initialProps) if (descriptors.length === 0) return initialProps let composedProps = withoutSsrMix(initialProps) let mixinProps = withoutSsrMixinTreeProps(composedProps) let maxDescriptors = 1024 for (let index = 0; index < descriptors.length && index < maxDescriptors; index++) { let descriptor = descriptors[index] let runner = resolveSsrMixinRunner(hostType, descriptor, context, frameState) if (!runner) continue let result: unknown try { result = runner(...descriptor.args, mixinProps) } catch (error) { console.error(error) continue } if (!result) continue if (isSsrMixinElement(result)) continue let returnedDescriptors = resolveReturnedSsrMixDescriptors(result) if (returnedDescriptors) { for (let returned of returnedDescriptors) descriptors.push(returned) continue } if (!isRemixElement(result)) { console.error(new Error('mixins must return a remix element')) continue } let remixResult = result let resultType = typeof remixResult.type === 'string' ? remixResult.type : isSsrMixinElement(remixResult.type) ? remixResult.type.__rmxMixinElementType : null if (resultType !== hostType) { console.error(new Error('mixins must return an element with the same host type')) continue } if (remixResult.type !== resultType) { remixResult = { ...remixResult, type: resultType } } let nextProps = sanitizeReturnedSsrMixinProps(remixResult.props as ElementProps) let nestedDescriptors = resolveSsrMixDescriptors(nextProps) for (let nested of nestedDescriptors) descriptors.push(nested) composedProps = { ...composedProps, ...withoutSsrMix(nextProps) } mixinProps = withoutSsrMixinTreeProps(composedProps) } let nextMix = initialProps.mix return { ...composedProps, ...(nextMix === undefined ? {} : { mix: nextMix }), } } function resolveSsrMixinRunner( hostType: string, descriptor: { type?: unknown; args?: unknown[] }, context: RenderContext, frameState: SsrFrameState, ): ((...args: unknown[]) => unknown) | null { if (typeof descriptor.type !== 'function') return null try { let handle = createSsrMixinHandle(hostType, descriptor, context, frameState) let runner = descriptor.type(handle, hostType) if (typeof runner !== 'function') return null return runner } catch (error) { console.error(error) return null } } function createSsrMixinHandle( hostType: string, _descriptor: { type?: unknown }, context: RenderContext, frameState: SsrFrameState, ) { let element = ((handle: { props: ElementProps; update(): Promise }) => () => ({ $rmx: true as const, type: hostType, key: null, props: handle.props, })) as ((handle: { props: ElementProps update(): Promise }) => () => RemixElement) & { __rmxMixinElementType: string } element.__rmxMixinElementType = hostType return { id: 'ssr-mixin', context: { get(providerType: ElementType | symbol) { if (typeof providerType !== 'function') { return undefined } let current = context.parentVNode while (current) { if (current.type === providerType) { let providerHandle = current._handle if (providerHandle) { return providerHandle.getContextValue() } } current = current._parent } return undefined }, }, frame: createFrameHandle({ src: frameState.frame.src, $runtime: { styleCache: context.styleCache, }, }), element, signal: ssrSignal, update: () => { throw new Error('handle.update() is not available during SSR.') }, queueTask: () => {}, on: () => {}, addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true, } } function resolveSsrMixDescriptors(props: ElementProps): Array<{ type: any; args: unknown[] }> { let mix = props.mix if (!mix) return [] if (Array.isArray(mix)) { if (mix.length === 0) return [] return mix.filter(Boolean) as Array<{ type: any; args: unknown[] }> } return [mix] as Array<{ type: any; args: unknown[] }> } function withoutSsrMix(props: ElementProps): ElementProps { if (!('mix' in props)) return props let output = { ...props } delete output.mix return output } function withoutSsrMixinTreeProps(props: ElementProps): ElementProps { if (!('children' in props) && !('innerHTML' in props)) return props let output = { ...props } delete output.children delete output.innerHTML return output } function sanitizeReturnedSsrMixinProps(props: ElementProps): ElementProps { if (!('children' in props) && !('innerHTML' in props)) return props console.error(new Error('mixins must not return children or innerHTML')) return withoutSsrMixinTreeProps(props) } function resolveReturnedSsrMixDescriptors( value: unknown, ): Array<{ type: Function; args: unknown[] }> | null { let descriptors: Array<{ type: Function; args: unknown[] }> = [] if (!collectReturnedSsrMixDescriptors(value, descriptors)) { return null } return descriptors } function collectReturnedSsrMixDescriptors( value: unknown, output: Array<{ type: Function; args: unknown[] }>, ): boolean { if (!value) { return true } if (Array.isArray(value)) { for (let item of value) { if (!collectReturnedSsrMixDescriptors(item, output)) { return false } } return true } if (!isSsrMixinDescriptor(value)) { return false } output.push(value) return true } function isSsrMixinElement( value: unknown, ): value is ((...args: unknown[]) => unknown) & { __rmxMixinElementType: string } { if (typeof value !== 'function') return false return '__rmxMixinElementType' in value } function isSsrMixinDescriptor(value: unknown): value is { type: Function; args: unknown[] } { if (!value || typeof value !== 'object' || isRemixElement(value)) { return false } let descriptor = value as { type?: unknown; args?: unknown } return typeof descriptor.type === 'function' && Array.isArray(descriptor.args) } function buildComponentSegment( type: Function, props: any, context: RenderContext, componentId: string, frameState: SsrFrameState, ): Segment { let vnode = createVNode(type, props) if (context.parentVNode) { vnode._parent = context.parentVNode } let handle = createComponent({ id: componentId, type: type, frame: frameState.frame, signal: ssrSignal, getContext(providerType) { let current = vnode._parent while (current) { if (current.type === providerType) { let providerHandle = current._handle // TODO: need better vnode types to avoid defensive checks if (providerHandle) { return providerHandle.getContextValue() } } current = current._parent } return undefined }, getFrameByName() { return undefined }, getTopFrame() { return frameState.topFrame }, }) vnode._handle = handle let [renderedNode] = handle.render(props) let childContext = { ...context, parentVNode: vnode } let rendered = buildSegment(renderedNode, childContext, frameState) if (childContext.flushKind === 'document') { context.flushKind = 'document' } return rendered } function createHydrationPropsReplacer(context: RenderContext, frameState: SsrFrameState) { function unwrapNode(node: RemixNode): unknown { if (node === null || node === undefined || typeof node === 'boolean') return node if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') { return node } if (Array.isArray(node)) { return node.map((child) => unwrapNode(child)) } if (isRemixElement(node)) { return unwrapElement(node) } return node } function unwrapElement(element: RemixElement): unknown { let type = element.type let props = element.props // Preserve Frame semantics through serialized props by emitting // a dedicated descriptor that can be revived on the client. if (type === Frame) { return { $rmxFrame: true, props: transformProps(props), key: element.key, } } // If it's a DOM tag, return a serializable shape with transformed props if (typeof type === 'string') { return { $rmx: true, type, props: transformProps(props) } } // Component function: render synchronously, then unwrap its result if (typeof type === 'function') { let vnode = createVNode(type, props) if (context.parentVNode) { vnode._parent = context.parentVNode } let handle = createComponent({ id: 'SERIALIZED', type: type, frame: frameState.frame, signal: ssrSignal, getContext(providerType) { let current = vnode._parent while (current) { if (current.type === providerType) { let providerHandle = current._handle if (providerHandle) { return providerHandle.getContextValue() } } current = current._parent } return undefined }, getFrameByName() { return undefined }, getTopFrame() { return frameState.topFrame }, }) vnode._handle = handle let [renderedNode] = handle.render(props) return unwrapNode(renderedNode) } return null } function transformProps(input: ElementProps): Record { let out: Record = {} for (let key in input) { let value = input[key] if (key === 'children') { out[key] = unwrapNode(value) } else { if (isRemixElement(value)) { out[key] = unwrapNode(value) } else if (Array.isArray(value)) { out[key] = value.map((v) => unwrapNode(v)) } else { out[key] = value } } } return out } return function replacer(_key: string, value: unknown) { if (isRemixElement(value)) { return unwrapElement(value) } if (Array.isArray(value)) { return value.map((v) => unwrapNode(v)) } return value } } function buildEntrySegment( type: EntryComponent, props: any, context: RenderContext, frameState: SsrFrameState, ): Segment { let instanceId = randomId('h') let rendered = buildComponentSegment(type, props, context, instanceId, frameState) // Store hydration data in context for aggregation let replacer = createHydrationPropsReplacer(context, frameState) context.unresolvedHydrationData.set(instanceId, { entryId: type.$entryId, component: type, props: JSON.parse(JSON.stringify(props, replacer)), }) let start = staticSeg(``) let end = staticSeg('') return compositeSeg([start, rendered, end]) } function resolveDefaultClientEntry( entryId: string, component: EntryComponent, ): ResolvedClientEntry { let fallbackExportName = component.name || '' let hashIndex = entryId.lastIndexOf('#') if (hashIndex === -1 && fallbackExportName) { return { exportName: fallbackExportName, href: entryId, } } if (hashIndex !== -1) { let exportName = entryId.slice(hashIndex + 1) || fallbackExportName if (exportName) { return { exportName, href: entryId.slice(0, hashIndex), } } } throw new Error( `clientEntry() requires either an export name in the entry ID (e.g., "/js/module.js#ComponentName"), a named component function, or a resolveClientEntry hook that resolves one. Received "${entryId}".`, ) } async function resolveClientEntries( context: RenderContext, resolveClientEntry?: ( entryId: string, component: EntryComponent, ) => Promise | ResolvedClientEntry, ): Promise { if (context.unresolvedHydrationData.size === 0) return let resolvedEntries = new Map() for (let [hydrationId, unresolvedHydrationData] of context.unresolvedHydrationData) { let { entryId, component, props } = unresolvedHydrationData let resolvedEntry = resolvedEntries.get(entryId) if (!resolvedEntry) { resolvedEntry = resolveClientEntry ? await Promise.resolve(resolveClientEntry(entryId, component)) : resolveDefaultClientEntry(entryId, component) validateResolvedClientEntry(entryId, resolvedEntry) resolvedEntries.set(entryId, resolvedEntry) } context.hydrationData.set(hydrationId, { exportName: resolvedEntry.exportName, moduleUrl: resolvedEntry.href, props, }) } context.unresolvedHydrationData.clear() } function validateResolvedClientEntry( entryId: string, resolvedEntry: ResolvedClientEntry, ): asserts resolvedEntry is ResolvedClientEntry { if (!resolvedEntry || typeof resolvedEntry !== 'object') { throw new Error( `resolveClientEntry must return an object with href and exportName. Received "${entryId}".`, ) } if (!resolvedEntry.href) { throw new Error(`resolveClientEntry must return a non-empty href. Received "${entryId}".`) } if (!resolvedEntry.exportName) { throw new Error(`resolveClientEntry must return a non-empty exportName. Received "${entryId}".`) } } function validateClientEntriesForHydration(context: RenderContext): void { if (context.unresolvedHydrationData.size > 0) { let [hydrationId, unresolvedHydrationData] = context.unresolvedHydrationData.entries().next() .value as [string, UnresolvedHydrationData] throw new Error( `Client entry was not resolved for hydration. Received "${unresolvedHydrationData.entryId}" (${hydrationId}).`, ) } } // Resolve all blocking frame content once async function resolveBlocking(segment: Segment): Promise { if (segment.kind === 'frame') { if (segment.pending) { await segment.pending segment.pending = undefined } if (segment.content) await resolveBlocking(segment.content) return } if (segment.kind === 'composite') { for (let part of segment.parts) { await resolveBlocking(part) } } } // Serialize the segment tree to HTML function serializeSegment(seg: Segment): string { if (seg.kind === 'static') return seg.html if (seg.kind === 'composite') return seg.parts.map(serializeSegment).join('') // frame let inner = seg.content ? serializeSegment(seg.content) : '' let start = `` let end = `` return start + inner + end } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function escapeTextContent(str: string): string { return str.replace(/&/g, '&').replace(//g, '>') } function escapeTemplateContent(html: string): string { return html.replace(/<\/template/gi, '<\\/template') } function transformAttributeName(name: string, isSvg: boolean): string { return normalizeAttributeName(name, isSvg).attr } function finalizeHtml(html: string, context: RenderContext): string { let hasHtmlRoot = context.flushKind === 'document' let styles = collectStyleTags(context) if (styles) { let headContent = styles if (hasHtmlRoot) { // For HTML root, inject into existing head or create one let headCloseIndex = html.indexOf('') if (headCloseIndex !== -1) { // Inject before existing html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex) } else { // No existing head, inject after let htmlOpenMatch = html.match(/]*>/) if (htmlOpenMatch) { let insertIndex = htmlOpenMatch.index! + htmlOpenMatch[0].length html = html.slice(0, insertIndex) + `${headContent}` + html.slice(insertIndex) } } } else { // No HTML root, prepend head html = `${headContent}${html}` } } html = dedupeServerStyleTagsInHtml(html, context.emittedStyles) // Append aggregated hydration/frame data script at the end let rmxData = buildRmxDataScript(context) if (rmxData) { if (hasHtmlRoot) { // Insert before if present, otherwise before let bodyCloseIndex = html.indexOf('') if (bodyCloseIndex !== -1) { html = html.slice(0, bodyCloseIndex) + rmxData + html.slice(bodyCloseIndex) } else { let htmlCloseIndex = html.indexOf('') if (htmlCloseIndex !== -1) { html = html.slice(0, htmlCloseIndex) + rmxData + html.slice(htmlCloseIndex) } else { html += rmxData } } } else { html += rmxData } } return html } function processStyleProps(props: any): any { let processedProps = { ...props } let classAttr = typeof props.class === 'string' ? props.class : '' let className = typeof props.className === 'string' ? props.className : '' let mergedClassName = [classAttr, className].filter(Boolean).join(' ') if (mergedClassName) { processedProps.className = mergedClassName delete processedProps.class } if (typeof props.style === 'object') { processedProps.style = serializeStyleObject(props.style) } return processedProps } function collectStyleTags(context: RenderContext): string { if (context.styleCache.size === 0) return '' let tags: string[] = [] for (let { selector, css } of context.styleCache.values()) { let tag = renderStyleTag(selector, css) if (tag) tags.push(tag) } return tags.join('') } function wrapStyleForLayer( selector: string, css: string, layer: string = REMIX_UI_STYLE_LAYER, ): string { let trimmed = css.trim() if (!trimmed) return '' return `@layer ${getStyleLayerName(selector, layer)} { ${trimmed} }` } function renderStyleTag( selector: string, css: string, layer: string = REMIX_UI_STYLE_LAYER, ): string { let wrappedCss = wrapStyleForLayer(selector, css, layer) if (!wrappedCss) return '' return `` } function readStyleTagAttribute(attrs: string, name: string): string | null { let match = attrs.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`)) if (!match) return null return match[1] ?? match[2] ?? null } function dedupeServerStyleTagsInHtml(html: string, seenStyles: Set): string { return html.replace(/]*)>[\s\S]*?<\/style>/gi, (match, attrs) => { let selector = readStyleTagAttribute(attrs, 'data-rmx') if (!selector) return match if (seenStyles.has(selector)) return '' seenStyles.add(selector) return match }) } function buildRmxDataScript(context: RenderContext): string { if (context.hydrationData.size === 0 && context.frameData.size === 0) { return '' } let data: { h?: Record f?: Record } = {} if (context.hydrationData.size > 0) { data.h = Object.fromEntries(context.hydrationData) } if (context.frameData.size > 0) { data.f = Object.fromEntries(context.frameData) } let serializedData = escapeScriptJson(JSON.stringify(data)) return `` } function escapeScriptJson(json: string): string { // Avoid prematurely closing the script tag when serialized data contains "". return json.replace(/` tags in its HTML, and on the client, // the `adoptServerStyleTag` MutationObserver (stylesheet.ts) picks it up anywhere in the // document and adopts the CSS into an adopted stylesheet. async function streamPendingFrames( context: RenderContext, controller: ReadableStreamDefaultController, encoder: TextEncoder, ): Promise { let processedFrames = new Set() while (true) { if (context.signal.aborted) break let batch = context.pendingFrames.filter(({ frameId }) => !processedFrames.has(frameId)) if (batch.length === 0) break await Promise.all( batch.map(async ({ frameId, promise }) => { if (context.signal.aborted) return processedFrames.add(frameId) try { let { html, tail } = await promise if (context.signal.aborted) return html = dedupeServerStyleTagsInHtml(html, context.emittedStyles) // Stream as a template element (first chunk only) let templateHtml = `` if (context.signal.aborted) return controller.enqueue(encoder.encode(templateHtml)) // Forward any additional chunks from a stream-valued resolveFrame result. if (tail) { await streamByteStreams([tail], controller, context) } } catch (error) { if (!isSignalAbortError(context.signal, error)) { context.onError(error) } } }), ) } } async function streamByteStreams( streams: ReadableStream[], controller: ReadableStreamDefaultController, context: RenderContext, ): Promise { await Promise.all( streams.map(async (stream) => { let reader = stream.getReader() try { while (true) { if (context.signal.aborted) break let { done, value } = await reader.read() if (done) break if (context.signal.aborted) break controller.enqueue(value) } } catch (error) { if (!isSignalAbortError(context.signal, error)) { context.onError(error) } } finally { reader.releaseLock() } }), ) } async function drain(stream: ReadableStream): Promise { let reader = stream.getReader() let decoder = new TextDecoder() let html = '' while (true) { let { done, value } = await reader.read() if (done) break html += decoder.decode(value) } return html } /** * Renders a node tree to a complete HTML string. * * @param node Node tree to render. * @returns Rendered HTML. */ export async function renderToString(node: RemixNode): Promise { return stripFlushMarkers( await drain( renderToStream(node, { onError(error) { throw error }, }), ), ) }