preact-render-to-string
Version:
Render JSX to an HTML string, with support for Preact components.
386 lines (329 loc) • 9.76 kB
JavaScript
import { encodeEntities, styleObjToCss, UNSAFE_NAME, XLINK } from './util';
import { options, h, Fragment } from 'preact';
import {
CHILDREN,
COMMIT,
COMPONENT,
DIFF,
DIFFED,
DIRTY,
NEXT_STATE,
PARENT,
RENDER,
SKIP_EFFECTS,
VNODE
} from './constants';
/** @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
);
} 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<string, unknown>} context
*/
function renderClassComponent(vnode, context) {
let type = /** @type {import("preact").ComponentClass<typeof vnode.props>} */ (vnode.type);
let 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 (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;
}
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
* @returns {string}
*/
function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
// 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 = '';
parent[CHILDREN] = vnode;
for (let i = 0; i < vnode.length; i++) {
let child = vnode[i];
if (child == null || typeof child === 'boolean') continue;
rendered =
rendered +
_renderToString(child, context, isSvgMode, selectValue, parent);
}
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) {
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());
}
}
// 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;
// Recurse into children before invoking the after-diff hook
const str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode
);
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = undefined;
if (ummountHook) ummountHook(vnode);
return str;
}
// 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) {
// <textarea value="a&b"> --> <textarea>a&b</textarea>
case 'textarea':
children = v;
continue;
// <select value> is serialized as a selected attribute on the matching option child
case 'select':
selectValue = v;
continue;
// Add a selected attribute to <option> if its value matches the parent <select> 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;
default: {
if (isSvgMode && XLINK.test(name)) {
name = name.toLowerCase().replace(XLINK_REPLACE_REGEX, 'xlink:');
} 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 += '';
}
}
}
// 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);
}
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = undefined;
if (ummountHook) ummountHook(vnode);
// Emit self-closing tag for empty void elements:
if (!html && SELF_CLOSING.has(type)) {
return s + '/>';
}
return s + '>' + html + '</' + type + '>';
}
const XLINK_REPLACE_REGEX = /^xlink:?/;
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;