import { encodeEntities, styleObjToCss, UNSAFE_NAME, NAMESPACE_REPLACE_REGEX, HTML_LOWER_CASE, SVG_CAMEL_CASE } from './util.js'; import { options, h, Fragment } from 'preact'; import { CHILDREN, COMMIT, COMPONENT, DIFF, DIFFED, DIRTY, NEXT_STATE, PARENT, RENDER, SKIP_EFFECTS, VNODE } from './constants.js'; /** @typedef {import('preact').VNode} VNode */ const EMPTY_ARR = []; const isArray = Array.isArray; const assign = Object.assign; // Global state for the current render pass let beforeDiff, afterDiff, renderHook, ummountHook; /** * Render Preact JSX + Components to an HTML string. * @param {VNode} vnode JSX Element / VNode to render * @param {Object} [context={}] Initial root context object * @returns {string} serialized HTML */ export function renderToString(vnode, context) { // Performance optimization: `renderToString` is synchronous and we // therefore don't execute any effects. To do that we pass an empty // array to `options._commit` (`__c`). But we can go one step further // and avoid a lot of dirty checks and allocations by setting // `options._skipEffects` (`__s`) too. const previousSkipEffects = options[SKIP_EFFECTS]; options[SKIP_EFFECTS] = true; // store options hooks once before each synchronous render call beforeDiff = options[DIFF]; afterDiff = options[DIFFED]; renderHook = options[RENDER]; ummountHook = options.unmount; const parent = h(Fragment, null); parent[CHILDREN] = [vnode]; try { return _renderToString( vnode, context || EMPTY_OBJ, false, undefined, parent, false ); } catch (e) { if (e.then) { throw new Error('Use "renderToStringAsync" for suspenseful rendering.'); } throw e; } finally { // options._commit, we don't schedule any effects in this library right now, // so we can pass an empty queue to this hook. if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR); options[SKIP_EFFECTS] = previousSkipEffects; EMPTY_ARR.length = 0; } } /** * Render Preact JSX + Components to an HTML string. * @param {VNode} vnode JSX Element / VNode to render * @param {Object} [context={}] Initial root context object * @returns {string} serialized HTML */ export async function renderToStringAsync(vnode, context) { // Performance optimization: `renderToString` is synchronous and we // therefore don't execute any effects. To do that we pass an empty // array to `options._commit` (`__c`). But we can go one step further // and avoid a lot of dirty checks and allocations by setting // `options._skipEffects` (`__s`) too. const previousSkipEffects = options[SKIP_EFFECTS]; options[SKIP_EFFECTS] = true; // store options hooks once before each synchronous render call beforeDiff = options[DIFF]; afterDiff = options[DIFFED]; renderHook = options[RENDER]; ummountHook = options.unmount; const parent = h(Fragment, null); parent[CHILDREN] = [vnode]; try { const rendered = _renderToString( vnode, context || EMPTY_OBJ, false, undefined, parent, true ); if (Array.isArray(rendered)) { let count = 0; let resolved = rendered; // Resolving nested Promises with a maximum depth of 25 while ( resolved.some((element) => typeof element.then === 'function') && count++ < 25 ) { resolved = (await Promise.all(resolved)).flat(); } return resolved.join(''); } return rendered; } finally { // options._commit, we don't schedule any effects in this library right now, // so we can pass an empty queue to this hook. if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR); options[SKIP_EFFECTS] = previousSkipEffects; EMPTY_ARR.length = 0; } } // Installed as setState/forceUpdate for function components function markAsDirty() { this.__d = true; } const EMPTY_OBJ = {}; /** * @param {VNode} vnode * @param {Record} context */ function renderClassComponent(vnode, context) { let type = /** @type {import("preact").ComponentClass} */ (vnode.type); let isMounting = true; let c; if (vnode[COMPONENT]) { isMounting = false; c = vnode[COMPONENT]; c.state = c[NEXT_STATE]; } else { c = new type(vnode.props, context); } vnode[COMPONENT] = c; c[VNODE] = vnode; c.props = vnode.props; c.context = context; // turn off stateful re-rendering: c[DIRTY] = true; if (c.state == null) c.state = EMPTY_OBJ; if (c[NEXT_STATE] == null) { c[NEXT_STATE] = c.state; } if (type.getDerivedStateFromProps) { c.state = assign( {}, c.state, type.getDerivedStateFromProps(c.props, c.state) ); } else if (isMounting && c.componentWillMount) { c.componentWillMount(); // If the user called setState in cWM we need to flush pending, // state updates. This is the same behaviour in React. c.state = c[NEXT_STATE] !== c.state ? c[NEXT_STATE] : c.state; } else if (!isMounting && c.componentWillUpdate) { c.componentWillUpdate(); } if (renderHook) renderHook(vnode); return c.render(c.props, c.state, context); } /** * Recursively render VNodes to HTML. * @param {VNode|any} vnode * @param {any} context * @param {boolean} isSvgMode * @param {any} selectValue * @param {VNode} parent * @param {boolean} asyncMode * @returns {string | Promise | (string | Promise)[]} */ function _renderToString( vnode, context, isSvgMode, selectValue, parent, asyncMode ) { // Ignore non-rendered VNodes/values if (vnode == null || vnode === true || vnode === false || vnode === '') { return ''; } // Text VNodes: escape as HTML if (typeof vnode !== 'object') { if (typeof vnode === 'function') return ''; return encodeEntities(vnode + ''); } // Recurse into children / Arrays if (isArray(vnode)) { let rendered = '', renderArray; parent[CHILDREN] = vnode; for (let i = 0; i < vnode.length; i++) { let child = vnode[i]; if (child == null || typeof child === 'boolean') continue; const childRender = _renderToString( child, context, isSvgMode, selectValue, parent, asyncMode ); if (typeof childRender === 'string') { rendered += childRender; } else { renderArray = renderArray || []; if (rendered) renderArray.push(rendered); rendered = ''; if (Array.isArray(childRender)) { renderArray.push(...childRender); } else { renderArray.push(childRender); } } } if (renderArray) { if (rendered) renderArray.push(rendered); return renderArray; } return rendered; } // VNodes have {constructor:undefined} to prevent JSON injection: if (vnode.constructor !== undefined) return ''; vnode[PARENT] = parent; if (beforeDiff) beforeDiff(vnode); let type = vnode.type, props = vnode.props, cctx = context, contextType, rendered, component; // Invoke rendering on Components if (typeof type === 'function') { if (type === Fragment) { // Serialized precompiled JSX. if (props.tpl) { let out = ''; for (let i = 0; i < props.tpl.length; i++) { out += props.tpl[i]; if (props.exprs && i < props.exprs.length) { const value = props.exprs[i]; if (value == null) continue; // Check if we're dealing with a vnode or an array of nodes if ( typeof value === 'object' && (value.constructor === undefined || isArray(value)) ) { out += _renderToString( value, context, isSvgMode, selectValue, vnode, asyncMode ); } else { // Values are pre-escaped by the JSX transform out += value; } } } return out; } else if (props.UNSTABLE_comment) { // Fragments are the least used components of core that's why // branching here for comments has the least effect on perf. return ''; } rendered = props.children; } else { contextType = type.contextType; if (contextType != null) { let provider = context[contextType.__c]; cctx = provider ? provider.props.value : contextType.__; } if (type.prototype && typeof type.prototype.render === 'function') { rendered = /**#__NOINLINE__**/ renderClassComponent(vnode, cctx); component = vnode[COMPONENT]; } else { component = { __v: vnode, props, context: cctx, // silently drop state updates setState: markAsDirty, forceUpdate: markAsDirty, __d: true, // hooks __h: [] }; vnode[COMPONENT] = component; // If a hook invokes setState() to invalidate the component during rendering, // re-render it up to 25 times to allow "settling" of memoized states. // Note: // This will need to be updated for Preact 11 to use internal.flags rather than component._dirty: // https://github.com/preactjs/preact/blob/d4ca6fdb19bc715e49fd144e69f7296b2f4daa40/src/diff/component.js#L35-L44 let count = 0; while (component[DIRTY] && count++ < 25) { component[DIRTY] = false; if (renderHook) renderHook(vnode); rendered = type.call(component, props, cctx); } component[DIRTY] = true; } if (component.getChildContext != null) { context = assign({}, context, component.getChildContext()); } if ( (type.getDerivedStateFromError || component.componentDidCatch) && options.errorBoundaries ) { let str = ''; // When a component returns a Fragment node we flatten it in core, so we // need to mirror that logic here too let isTopLevelFragment = rendered != null && rendered.type === Fragment && rendered.key == null; rendered = isTopLevelFragment ? rendered.props.children : rendered; try { str = _renderToString( rendered, context, isSvgMode, selectValue, vnode, asyncMode ); return str; } catch (err) { if (type.getDerivedStateFromError) { component[NEXT_STATE] = type.getDerivedStateFromError(err); } if (component.componentDidCatch) { component.componentDidCatch(err, {}); } if (component[DIRTY]) { rendered = renderClassComponent(vnode, context); component = vnode[COMPONENT]; if (component.getChildContext != null) { context = assign({}, context, component.getChildContext()); } let isTopLevelFragment = rendered != null && rendered.type === Fragment && rendered.key == null; rendered = isTopLevelFragment ? rendered.props.children : rendered; str = _renderToString( rendered, context, isSvgMode, selectValue, vnode, asyncMode ); } return str; } finally { if (afterDiff) afterDiff(vnode); vnode[PARENT] = null; if (ummountHook) ummountHook(vnode); } } } // When a component returns a Fragment node we flatten it in core, so we // need to mirror that logic here too let isTopLevelFragment = rendered != null && rendered.type === Fragment && rendered.key == null && rendered.props.tpl == null; rendered = isTopLevelFragment ? rendered.props.children : rendered; try { // Recurse into children before invoking the after-diff hook const str = _renderToString( rendered, context, isSvgMode, selectValue, vnode, asyncMode ); if (afterDiff) afterDiff(vnode); vnode[PARENT] = null; if (ummountHook) ummountHook(vnode); return str; } catch (error) { if (!asyncMode) throw error; if (!error || typeof error.then !== 'function') throw error; const renderNestedChildren = () => { try { return _renderToString( rendered, context, isSvgMode, selectValue, vnode, asyncMode ); } catch (e) { if (!e || typeof e.then !== 'function') throw e; return e.then( () => _renderToString( rendered, context, isSvgMode, selectValue, vnode, asyncMode ), () => renderNestedChildren() ); } }; return error.then(() => renderNestedChildren()); } } // Serialize Element VNodes to HTML let s = '<' + type, html = '', children; for (let name in props) { let v = props[name]; switch (name) { case 'children': children = v; continue; // VDOM-specific props case 'key': case 'ref': case '__self': case '__source': continue; // prefer for/class over htmlFor/className case 'htmlFor': if ('for' in props) continue; name = 'for'; break; case 'className': if ('class' in props) continue; name = 'class'; break; // Form element reflected properties case 'defaultChecked': name = 'checked'; break; case 'defaultSelected': name = 'selected'; break; // Special value attribute handling case 'defaultValue': case 'value': name = 'value'; switch (type) { // case 'textarea': children = v; continue; // value case 'option': if (selectValue == v && !('selected' in props)) { s = s + ' selected'; } break; } break; case 'dangerouslySetInnerHTML': html = v && v.__html; continue; // serialize object styles to a CSS string case 'style': if (typeof v === 'object') { v = styleObjToCss(v); } break; case 'acceptCharset': name = 'accept-charset'; break; case 'httpEquiv': name = 'http-equiv'; break; default: { if (NAMESPACE_REPLACE_REGEX.test(name)) { name = name.replace(NAMESPACE_REPLACE_REGEX, '$1:$2').toLowerCase(); } else if (UNSAFE_NAME.test(name)) { continue; } else if ((name[4] === '-' || name === 'draggable') && v != null) { // serialize boolean aria-xyz or draggable attribute values as strings // `draggable` is an enumerated attribute and not Boolean. A value of `true` or `false` is mandatory // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable v += ''; } else if (isSvgMode) { if (SVG_CAMEL_CASE.test(name)) { name = name === 'panose1' ? 'panose-1' : name.replace(/([A-Z])/g, '-$1').toLowerCase(); } } else if (HTML_LOWER_CASE.test(name)) { name = name.toLowerCase(); } } } // write this attribute to the buffer if (v != null && v !== false && typeof v !== 'function') { if (v === true || v === '') { s = s + ' ' + name; } else { s = s + ' ' + name + '="' + encodeEntities(v + '') + '"'; } } } if (UNSAFE_NAME.test(type)) { // this seems to performs a lot better than throwing // return ''; throw new Error(`${type} is not a valid HTML tag name in ${s}>`); } if (html) { // dangerouslySetInnerHTML defined this node's contents } else if (typeof children === 'string') { // single text child html = encodeEntities(children); } else if (children != null && children !== false && children !== true) { // recurse into this element VNode's children let childSvgMode = type === 'svg' || (type !== 'foreignObject' && isSvgMode); html = _renderToString( children, context, childSvgMode, selectValue, vnode, asyncMode ); } if (afterDiff) afterDiff(vnode); vnode[PARENT] = null; if (ummountHook) ummountHook(vnode); // Emit self-closing tag for empty void elements: if (!html && SELF_CLOSING.has(type)) { return s + '/>'; } const endTag = ''; const startTag = s + '>'; if (Array.isArray(html)) return [startTag, ...html, endTag]; else if (typeof html !== 'string') return [startTag, html, endTag]; return startTag + html + endTag; } const SELF_CLOSING = new Set([ 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' ]); export default renderToString; export const render = renderToString; export const renderToStaticMarkup = renderToString;