Skip to content

Benchmarks & Performance Improvements #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -3,3 +3,4 @@
/npm-debug.log
.DS_Store
/src/preact-render-to-string-tests.d.ts
test/bench/.dist.modern.js
14 changes: 7 additions & 7 deletions jsx.d.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { VNode } from 'preact';

interface Options {
jsx?: boolean;
xml?: boolean;
functions?: boolean
functionNames?: boolean,
skipFalseAttributes?: boolean
pretty?: boolean | string;
jsx?: boolean;
xml?: boolean;
functions?: boolean;
functionNames?: boolean;
skipFalseAttributes?: boolean;
pretty?: boolean | string;
}

export function render(vnode: VNode, context?: any, options?: Options):string;
export function render(vnode: VNode, context?: any, options?: Options): string;
export default render;
18,952 changes: 9,315 additions & 9,637 deletions package-lock.json

Large diffs are not rendered by default.

25 changes: 23 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -29,6 +29,9 @@
"copy-typescript-definition": "copyfiles -f src/*.d.ts dist",
"test": "eslint src test && tsc && npm run test:mocha",
"test:mocha": "mocha -r @babel/register test/**/*.js",
"bench:x": "node -r @babel/register benchmarks/index.js",
"bench:y": "babel benchmarks/*.js -d benchmarks/dist --env-name bench && v8 --module test/bench/benchmark.js",
"bench": "microbundle test/bench/index.js -f modern --external none --target node --no-compress --no-sourcemap -o test/bench/.dist.js && v8 --module test/bench/.dist.modern.js",
"format": "prettier src/**/*.{d.ts,js} test/**/*.js --write",
"prepublishOnly": "npm run build",
"release": "npm run build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
@@ -74,7 +77,22 @@
"pragma": "h"
}
]
]
],
"env": {
"bench": {
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": true
},
"modules": false
}
]
]
}
}
},
"author": "Jason Miller <jason@developit.ca>",
"license": "MIT",
@@ -86,9 +104,12 @@
"preact": ">=10"
},
"devDependencies": {
"@babel/cli": "^7.12.13",
"@babel/core": "^7.12.13",
"@babel/plugin-transform-react-jsx": "^7.12.12",
"@babel/preset-env": "^7.12.11",
"@babel/register": "^7.12.10",
"benchmarkjs-pretty": "^2.0.1",
"chai": "^4.2.0",
"copyfiles": "^2.4.1",
"eslint": "^7.16.0",
@@ -122,4 +143,4 @@
"pre-commit": "lint-staged"
}
}
}
}
9 changes: 7 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -39,9 +39,13 @@ renderToString.render = renderToString;
*/
let shallowRender = (vnode, context) => renderToString(vnode, context, SHALLOW);

let cache = {};

const EMPTY_ARR = [];
function renderToString(vnode, context, opts) {
cache = {};
const res = _renderToString(vnode, context, opts);
cache = {};
// 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.__c) options.__c(vnode, EMPTY_ARR);
@@ -70,7 +74,8 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {

// #text nodes
if (typeof vnode !== 'object' && !nodeName) {
return encodeEntities(vnode);
if (vnode in cache) return cache[vnode];
return (cache[vnode] = encodeEntities(vnode));
}

// components
@@ -132,7 +137,7 @@ function _renderToString(vnode, context, opts, inner, isSvgMode, selectValue) {
: context;

// stateless functional components
rendered = nodeName.call(vnode.__c, props, cctx);
rendered = nodeName.call(c, props, cctx);
} else {
// class-based components
let cxType = nodeName.contextType;
371 changes: 371 additions & 0 deletions src/ropes-str.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,371 @@
import { encodeEntities, styleObjToCss, assign, getChildren } from './util';
import { options, Fragment } from 'preact';

const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;

const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;

function noop() {}

let s = '';
let opts = {};
let cache = {};
let cache2 = {};

/**
* Render Preact JSX + Components to an HTML string.
* @param {import('preact').VNode} vnode A Virtual DOM element to render.
*/
export default function (vnode, _opts) {
opts = _opts || {};
let oldS = s;
s = '';
try {
renderVNode(vnode, {}, null, false);
return s;
} finally {
s = oldS;
cache = {};
cache2 = {};
}
}

/**
* Render a Virtual DOM element (of any kind) to HTML.
* @param {import('preact').VNode|string|number|Array<import('preact').VNode|string|number>} any renderable value
* @param {object} context (forks throughout the tree)
* @param {any} the current select value, passed down through the tree to set <options selected>
* @param {{ s: string }} A "string buffer" object, for passing around references to the WIP output string "s".
*/
function renderVNode(vnode, context, selectValue, isSvgMode) {
if (vnode == null || typeof vnode === 'boolean') {
return;
}

// wrap array nodes in Fragment
if (typeof vnode === 'object') {
if (Array.isArray(vnode)) {
let children = [];
getChildren(children, vnode);
for (let i = 0; i < children.length; i++) {
renderVNode(children[i], context, selectValue, isSvgMode);
}
return;
}
} else {
s =
s +
(vnode in cache ? cache[vnode] : (cache[vnode] = encodeEntities(vnode)));
return;
}

let nodeName = vnode.type,
props = vnode.props,
isComponent = false;

context = context || {};

// components
if (typeof nodeName === 'function') {
if (nodeName === Fragment) {
renderVNode(vnode.props.children);
return;
}

isComponent = true;
// if (nodeName===Fragment) {
// let rendered = '';
// let children = [];
// getChildren(children, vnode.props.children);
// for (let i = 0; i < children.length; i++) {
// rendered += renderToString(children[i], context, selectValue);
// }
// return rendered;
// }

let rendered;

let c = (vnode.__c = {
__v: vnode,
context,
props: vnode.props,
// silently drop state updates
setState: noop,
forceUpdate: noop,
// hooks
__h: []
});

// options._diff
if (options.__b) options.__b(vnode);

// options.render
if (options.__r) options.__r(vnode);

if (!('prototype' in nodeName) || !('render' in nodeName.prototype)) {
// Necessary for createContext api. Setting this property will pass
// the context value as `this.context` just for this component.
let cxType = nodeName.contextType;
let provider = cxType && context[cxType.__c];
let cctx =
cxType != null
? provider
? provider.props.value
: cxType.__
: context;

// stateless functional components
rendered = nodeName.call(c, props, cctx);
} else {
// class-based components
let cxType = nodeName.contextType;
let provider = cxType && context[cxType.__c];
let cctx =
cxType != null
? provider
? provider.props.value
: cxType.__
: context;

c = vnode.__c = new nodeName(props, cctx);
c.__v = vnode;
// turn off stateful re-rendering:
c._dirty = c.__d = true;
c.props = props;
if (c.state == null) c.state = {};

if (c._nextState == null && c.__s == null) {
c._nextState = c.__s = c.state;
}

c.context = cctx;
if ('getDerivedStateFromProps' in nodeName) {
c.state = assign(
assign({}, c.state),
nodeName.getDerivedStateFromProps(c.props, c.state)
);
} else if ('componentWillMount' in c) {
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._nextState !== c.state
? c._nextState
: c.__s !== c.state
? c.__s
: c.state;

rendered = c.render(c.props, c.state, c.context);
}

if ('getChildContext' in c) {
context = assign(assign({}, context), c.getChildContext());
}

if (options.diffed) options.diffed(vnode);

renderVNode(rendered, context, selectValue, isSvgMode);
return;
}

/*
if (UNSAFE_NAME.test(nodeName)) return;
let s = '<';
// p.s += '<';
// p.s += nodeName;
let html;
s += nodeName;
if (props) {
if (nodeName === 'option' && selectValue === props.value) {
// p.s += ' selected';
s += ' selected';
}
for (let name in props) {
let v = props[name];
let type;
if (
name === 'children' ||
name === 'key' ||
name === 'ref' ||
UNSAFE_NAME.test(name)
) {
// skip
} else if (nodeName === 'select' && name === 'value') {
selectValue = v;
} else if (name === 'dangerouslySetInnerHTML') {
html = v && v.__html;
} else if ((v || v === 0) && (type = typeof v) !== 'function') {
if (name === 'style' && type === 'object') {
v = styleObjToCss(v);
} else if (name.substring(0, 5) === 'xlink') {
// this doesn't actually need to be limited to SVG, since attributes "xlinkHref" are invalid anyway
name = name.replace(/^xlink([A-Z])/, 'xlink:$1');
}
s += ' ';
s += name;
if (v !== true && v !== '') {
s += '="';
s += encodeEntities(v);
s += '"';
}
}
}
}
let isVoid = VOID_ELEMENTS.test(nodeName);
if (isVoid) {
s += ' />';
} else {
s += '>';
}
if (html) {
s += html;
} else if (props && props.children) {
renderVNode(props.children, context, selectValue, p);
}
if (!isVoid) {
s += '</';
s += nodeName;
s += '>';
}
*/

if (UNSAFE_NAME.test(nodeName))
throw new Error(`${nodeName} is not a valid HTML tag name in ${s}`);

s = s + '<' + nodeName;

// render JSX to HTML
let propChildren, html;

if (props) {
let attrs = Object.keys(props);

// allow sorting lexicographically for more determinism (useful for tests, such as via preact-jsx-chai)
if (opts.sortAttributes === true) attrs.sort();

for (let i = 0; i < attrs.length; i++) {
let name = attrs[i],
v = props[name];
if (name === 'children') {
propChildren = v;
continue;
}

if (UNSAFE_NAME.test(name)) continue;

if (
!opts.allAttributes &&
(name === 'key' ||
name === 'ref' ||
name === '__self' ||
name === '__source' ||
name === 'defaultValue')
)
continue;

if (name === 'className') {
if (props.class) continue;
name = 'class';
} else if (isSvgMode && name.indexOf('xlinkH') === 0) {
name = name.replace('H', ':h').toLowerCase();
}

if (name === 'htmlFor') {
if (props.for) continue;
name = 'for';
}

if (name === 'style' && v && typeof v === 'object') {
v = styleObjToCss(v);
}

// always use string values instead of booleans for aria attributes
// also see https://github.com/preactjs/preact/pull/2347/files
if (name[0] === 'a' && name['1'] === 'r' && typeof v === 'boolean') {
v = String(v);
}

let hooked =
opts.attributeHook &&
opts.attributeHook(name, v, context, opts, isComponent);
if (hooked || hooked === '') {
s = s + hooked;
continue;
}

if (name === 'dangerouslySetInnerHTML') {
html = v && v.__html;
} else if (nodeName === 'textarea' && name === 'value') {
// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
propChildren = v;
} else if ((v || v === 0 || v === '') && typeof v !== 'function') {
if (v === true || v === '') {
v = name;
// in non-xml mode, allow boolean attributes
if (!opts || !opts.xml) {
s = s + ' ' + name;
continue;
}
}

if (name === 'value') {
if (nodeName === 'select') {
selectValue = v;
continue;
} else if (nodeName === 'option' && selectValue == v) {
s = s + ` selected`;
}
}
// s += ` ${name}="${encodeEntities(v)}"`;
s =
s +
' ' +
name +
'="' +
(cache2[v] || (cache2[v] = encodeEntities(v))) +
'"';
}
}
}

// s += '>';

let isVoid =
String(nodeName).match(VOID_ELEMENTS) ||
(opts.voidElements && String(nodeName).match(opts.voidElements));

let children;
if (isVoid) {
s = s + ' />';
} else {
s = s + '>';
if (html) {
s = s + html;
} else if (
propChildren != null &&
getChildren((children = []), propChildren).length
) {
for (let i = 0; i < children.length; i++) {
let child = children[i];

if (child != null && child !== false) {
renderVNode(
child,
context,
selectValue,
nodeName === 'svg' || (nodeName !== 'foreignObject' && isSvgMode)
);
}
}
}
s = s + '</' + nodeName + '>';
}
}
390 changes: 390 additions & 0 deletions src/ropes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
import { encodeEntities, styleObjToCss, assign, getChildren } from './util';
import { options, Fragment } from 'preact';

const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;

const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;

function noop() {}

/**
* Current output buffer
* @type {string[]}
* */
let s = [];

/**
* Current text encoding cache
* @type {Map<string, string>}
*/
let cache = {};

/**
* Current attribute encoding cache
* @type {Map<string, string>}
*/
let attrCache = {};

/**
* Current rendering options
* @type {{ pretty, shallow, shallowHighOrder, renderRootComponent, sortAttributes, allAttributes, attributeHook, xml, voidElements }}
*/
let opts = {};

/**
* Render Preact JSX + Components to an HTML string.
* @param {import('preact').VNode} vnode A Virtual DOM element to render.
* @param {object} context Initial context for the root node
*/
export default function (vnode, _opts) {
let oldS = s;
let oldOpts = opts;
opts = _opts || {};
s = [];
try {
renderVNode(vnode, {}, null, false);
return s.join('');
} finally {
cache = {};
attrCache = {};
s = oldS;
opts = oldOpts;
}
}

/**
* Render a Virtual DOM element (of any kind) to HTML.
* @param {import('preact').VNode|string|number|Array<import('preact').VNode|string|number>} any renderable value
* @param {object} context (forks throughout the tree)
* @param {any} selectValue the current select value, passed down through the tree to set <options selected>
* @param {boolean} isSvgMode are we rendering within an SVG?
*/
function renderVNode(vnode, context, selectValue, isSvgMode) {
if (vnode == null || typeof vnode === 'boolean') {
return;
}

// wrap array nodes in Fragment
if (typeof vnode === 'object') {
if (Array.isArray(vnode)) {
let children = [];
getChildren(children, vnode);
for (let i = 0; i < children.length; i++) {
renderVNode(children[i], context, selectValue, isSvgMode);
}
return;
}
} else {
s.push(cache[vnode] || (cache[vnode] = encodeEntities(vnode)));
return;
}

let nodeName = vnode.type,
props = vnode.props,
isComponent = false;

context = context || {};

// components
if (typeof nodeName === 'function') {
if (nodeName === Fragment) {
renderVNode(vnode.props.children, context, selectValue, isSvgMode);
return;
}

isComponent = true;
// if (nodeName===Fragment) {
// let rendered = '';
// let children = [];
// getChildren(children, vnode.props.children);
// for (let i = 0; i < children.length; i++) {
// rendered += renderToString(children[i], context, selectValue);
// }
// return rendered;
// }

let rendered;

let c = (vnode.__c = {
__v: vnode,
context,
props: vnode.props,
// silently drop state updates
setState: noop,
forceUpdate: noop,
// hooks
__h: []
});

// options._diff
if (options.__b) options.__b(vnode);

// options.render
if (options.__r) options.__r(vnode);

if (!('prototype' in nodeName) || !('render' in nodeName.prototype)) {
// Necessary for createContext api. Setting this property will pass
// the context value as `this.context` just for this component.
let cxType = nodeName.contextType;
let provider = cxType && context[cxType.__c];
let cctx =
cxType != null
? provider
? provider.props.value
: cxType.__
: context;

// stateless functional components
rendered = nodeName.call(c, props, cctx);
} else {
// class-based components
let cxType = nodeName.contextType;
let provider = cxType && context[cxType.__c];
let cctx =
cxType != null
? provider
? provider.props.value
: cxType.__
: context;

c = vnode.__c = new nodeName(props, cctx);
c.__v = vnode;
// turn off stateful re-rendering:
c._dirty = c.__d = true;
c.props = props;
if (c.state == null) c.state = {};

if (c._nextState == null && c.__s == null) {
c._nextState = c.__s = c.state;
}

c.context = cctx;
if ('getDerivedStateFromProps' in nodeName) {
c.state = assign(
assign({}, c.state),
nodeName.getDerivedStateFromProps(c.props, c.state)
);
} else if ('componentWillMount' in c) {
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._nextState !== c.state
? c._nextState
: c.__s !== c.state
? c.__s
: c.state;

rendered = c.render(c.props, c.state, c.context);
}

if ('getChildContext' in c) {
context = assign(assign({}, context), c.getChildContext());
}

if (options.diffed) options.diffed(vnode);

renderVNode(rendered, context, selectValue, isSvgMode);
return;
}

/*
if (UNSAFE_NAME.test(nodeName)) return;
let s = '<';
// p.s += '<';
// p.s += nodeName;
let html;
s += nodeName;
if (props) {
if (nodeName === 'option' && selectValue === props.value) {
// p.s += ' selected';
s += ' selected';
}
for (let name in props) {
let v = props[name];
let type;
if (
name === 'children' ||
name === 'key' ||
name === 'ref' ||
UNSAFE_NAME.test(name)
) {
// skip
} else if (nodeName === 'select' && name === 'value') {
selectValue = v;
} else if (name === 'dangerouslySetInnerHTML') {
html = v && v.__html;
} else if ((v || v === 0) && (type = typeof v) !== 'function') {
if (name === 'style' && type === 'object') {
v = styleObjToCss(v);
} else if (name.substring(0, 5) === 'xlink') {
// this doesn't actually need to be limited to SVG, since attributes "xlinkHref" are invalid anyway
name = name.replace(/^xlink([A-Z])/, 'xlink:$1');
}
s += ' ';
s += name;
if (v !== true && v !== '') {
s += '="';
s += encodeEntities(v);
s += '"';
}
}
}
}
let isVoid = VOID_ELEMENTS.test(nodeName);
if (isVoid) {
s += ' />';
} else {
s += '>';
}
if (html) {
s += html;
} else if (props && props.children) {
renderVNode(props.children, context, selectValue, p);
}
if (!isVoid) {
s += '</';
s += nodeName;
s += '>';
}
*/

if (UNSAFE_NAME.test(nodeName))
throw new Error(`${nodeName} is not a valid HTML tag name in ${s}`);

let buf = '<' + nodeName;

// render JSX to HTML
let propChildren, html;

if (props) {
let attrs = Object.keys(props);

// allow sorting lexicographically for more determinism (useful for tests, such as via preact-jsx-chai)
if (opts.sortAttributes === true) attrs.sort();

for (let i = 0; i < attrs.length; i++) {
let name = attrs[i],
v = props[name];
if (name === 'children') {
propChildren = v;
continue;
}

if (UNSAFE_NAME.test(name)) continue;

if (
!opts.allAttributes &&
(name === 'key' ||
name === 'ref' ||
name === '__self' ||
name === '__source' ||
name === 'defaultValue')
)
continue;

if (name === 'className') {
if (props.class) continue;
name = 'class';
} else if (isSvgMode && name.indexOf('xlinkH') === 0) {
name = name.replace('H', ':h').toLowerCase();
}

if (name === 'htmlFor') {
if (props.for) continue;
name = 'for';
}

if (name === 'style' && v && typeof v === 'object') {
v = styleObjToCss(v);
}

// always use string values instead of booleans for aria attributes
// also see https://github.com/preactjs/preact/pull/2347/files
if (name[0] === 'a' && name['1'] === 'r' && typeof v === 'boolean') {
v = String(v);
}

let hooked =
opts.attributeHook &&
opts.attributeHook(name, v, context, opts, isComponent);
if (hooked || hooked === '') {
buf = buf + hooked;
continue;
}

if (name === 'dangerouslySetInnerHTML') {
html = v && v.__html;
} else if (nodeName === 'textarea' && name === 'value') {
// <textarea value="a&b"> --> <textarea>a&amp;b</textarea>
propChildren = v;
} else if ((v || v === 0 || v === '') && typeof v !== 'function') {
if (v === true || v === '') {
v = name;
// in non-xml mode, allow boolean attributes
if (!opts || !opts.xml) {
buf = buf + ' ' + name;
continue;
}
}

if (name === 'value') {
if (nodeName === 'select') {
selectValue = v;
continue;
} else if (nodeName === 'option' && selectValue == v) {
buf = buf + ' selected';
}
}
buf =
buf +
' ' +
name +
'="' +
(attrCache[v] || (attrCache[v] = encodeEntities(v))) +
'"';
}
}
}

// s += '>';

let isVoid =
String(nodeName).match(VOID_ELEMENTS) ||
(opts.voidElements && String(nodeName).match(opts.voidElements));

let children;
if (isVoid) {
s.push(buf + ' />');
} else {
s.push(buf + '>');
if (html) {
s.push(html);
} else if (
propChildren != null &&
getChildren((children = []), propChildren).length
) {
for (let i = 0; i < children.length; i++) {
let child = children[i];

if (child != null && child !== false) {
renderVNode(
child,
context,
selectValue,
nodeName === 'svg' || (nodeName !== 'foreignObject' && isSvgMode)
);
}
}
}
s.push(`</${nodeName}>`);
}
}
28 changes: 14 additions & 14 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
// DOM properties that should NOT have "px" added when numeric
export const IS_NON_DIMENSIONAL = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|^--/i;

export function encodeEntities(s) {
if (typeof s !== 'string') s = String(s);
let out = '';
for (let i = 0; i < s.length; i++) {
let ch = s[i];
// prettier-ignore
switch (ch) {
case '<': out += '&lt;'; break;
case '>': out += '&gt;'; break;
case '"': out += '&quot;'; break;
case '&': out += '&amp;'; break;
default: out += ch;
}
function replacer(ch) {
switch (ch) {
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
default:
return '&amp;';
}
return out;
}

export function encodeEntities(s) {
return (typeof s === 'string' ? s : String(s)).replace(/<>"&/g, replacer);
}

export let indent = (s, char) =>
31 changes: 31 additions & 0 deletions test/bench/fixtures/stack.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { h } from 'preact';

function Leaf() {
return (
<div>
<span class="foo" data-testid="stack">
deep stack
</span>
</div>
);
}

function PassThrough(props) {
return <div>{props.children}</div>;
}

function recursive(n) {
if (n <= 0) {
return <Leaf />;
}
return <PassThrough>{recursive(n - 1)}</PassThrough>;
}

const content = [];
for (let i = 0; i < 10; i++) {
content.push(recursive(1000));
}

export default function App() {
return <div>{content}</div>;
}
46 changes: 46 additions & 0 deletions test/bench/fixtures/text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { h } from 'preact';

function Bavaria() {
return (
<div>
<span class="foo" data-testid="foo">
Bavaria ipsum dolor sit amet gwiss Charivari Auffisteign koa. Umma
pfenningguat vui huift vui back mas Landla Bradwurschtsemmal,
Fingahaggln. Wolpern ja, wo samma denn wea nia ausgähd, kummt nia hoam
baddscher i moan oiwei! Kloan pfenningguat is Charivari Bussal,
hallelujah sog i, luja. Liberalitas Bavariae hod Schorsch om auf’n Gipfe
gwiss naa. Und ja, wo samma denn Ohrwaschl hoggd auffi Spotzerl
Diandldrahn, oba? Is sog i und glei wirds no fui lustiga Biaschlegl ma
nimma ned woar gscheckate, pfenningguat! Gstanzl dei Schorsch Radi i mog
di fei hea Reiwadatschi fensdaln dei glei a Hoiwe. Bitt umananda ghupft
wia gsprunga Gschicht kimmt, oamoi obandeln. Sog i helfgod amoi
hallelujah sog i, luja i hob di narrisch gean, Brodzeid. Wolln a Maß und
no a Maß Gaudi obandln eana boarischer hallelujah sog i, luja Maßkruag
greaßt eich nachad, Schmankal.
</span>
<span class="bar" data-testid="bar">
Dei um Godds wujn naa Watschnbaam Obazda Trachtnhuat, Vergeltsgott
Schneid Schbozal. Om auf’n Gipfe Ramasuri um Godds wujn eana. Wos
sammawiedaguad sei Weißwiaschd da, hog di hi is des liab des umananda
Brezn Sauakraud Diandldrahn. Vo de weida pfundig Kirwa de Sonn
Hetschapfah Watschnpladdla auf gehds beim Schichtl Meidromml auffi lem
und lem lossn! Watschnpladdla wolln measi obandeln griasd eich midnand
Oachkatzlschwoaf is ma Wuascht sammawiedaguad aasgem. A so a Schmarn
Weibaleid naa, des basd scho. Abfieseln helfgod Sauwedda middn ded
schoo. A bissal wos gehd ollaweil Sauwedda is Servas wiavui wo hi o’ha,
a liabs Deandl pfiad de nix. Maßkruag etza so spernzaln. Weiznglasl
Bradwurschtsemmal da, Schdeckalfisch: Mei Musi bitt des wiad a
Mordsgaudi kumm geh Biakriagal Greichats obacht?
</span>
</div>
);
}

const content = [];
for (let i = 0; i < 1000; i++) {
content.push(<Bavaria />);
}

export default function App() {
return <div>{content}</div>;
}
34 changes: 34 additions & 0 deletions test/bench/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { h } from 'preact';
import { describe, test, expect, benchmark } from './runner.js';
import renderToString from '../../src/index.js';
import renderToStringFast from '../../src/ropes.js';
import TextApp from './fixtures/text.js';
import StackApp from './fixtures/stack.js';

describe('performance', () => {
test('text', () => {
const html = renderToString(h(TextApp));
const html2 = renderToStringFast(h(TextApp));
expect(html.length).toEqual(html2.length);
const before = benchmark('before', () => {
if (renderToString(h(TextApp)) !== html) throw Error('mismatch');
});
const fast = benchmark('faster', () => {
if (renderToStringFast(h(TextApp)) !== html) throw Error('mismatch');
});
expect(fast.hz).toBeGreaterThan(before.hz);
});

test('stack', () => {
const html = renderToString(h(StackApp));
const html2 = renderToStringFast(h(StackApp));
expect(html.length).toEqual(html2.length);
const before = benchmark('before', () => {
if (renderToString(h(StackApp)) !== html) throw Error('mismatch');
});
const fast = benchmark('faster', () => {
if (renderToStringFast(h(StackApp)) !== html) throw Error('mismatch');
});
expect(fast.hz).toBeGreaterThan(before.hz);
});
});
211 changes: 211 additions & 0 deletions test/bench/runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/*global globalThis*/

export function benchmark(name, executor, iterations = 10, timeLimit = 5000) {
let count = 0;
let now = performance.now(),
start = now,
prev = now;
const times = [];
do {
for (let i = iterations; i--; ) executor(++count);
prev = now;
now = performance.now();
times.push((now - prev) / iterations);
} while (now - start < timeLimit);
const elapsed = now - start;
const hz = Math.round((count / elapsed) * 1000);
const average = toFixed(elapsed / count);
const middle = Math.floor(times.length / 2);
const middle2 = Math.ceil(times.length / 2);
times.sort((a, b) => a - b);
const median = toFixed((times[middle] + times[middle2]) / 2);
const hzStr = hz.toLocaleString();
const averageStr = average.toLocaleString();
const n = times.length;
const stdev = Math.sqrt(
times.reduce((c, t) => c + (t - average) ** 2, 0) / (n - 1)
);
const p95 = toFixed((1.96 * stdev) / Math.sqrt(n));
console.log(
`\x1b[36m${name}:\x1b[0m ${hzStr}/s, average: ${averageStr}ms ±${p95} (median: ${median}ms)`
);
return { elapsed, hz, average, median };
}
globalThis.benchmark = benchmark;
const toFixed = (v) => ((v * 100) | 0) / 100;

const queue = [];
let stack = [];
let index = 0;

async function process() {
const id = index++;
if (id === queue.length) {
queue.length = index = 0;
return;
}
const [op, name, fn, extra] = queue[id];
queue[id] = undefined;
await processors[op](name, fn, extra);
await process();
}

const processors = {
async describe(name, fn, path) {
stack.push(name);
log('INFO', name);
await fn();
stack.pop();
},
async test(name, fn, path) {
let stackBefore = stack;
stack = path.concat(name);
logBuffer = [];
await new Promise((resolve) => {
let calls = 0;
const done = () => {
if (calls++) throw Error(`Callback called multiple times\n\t${name}`);
// log('INFO', `${name}`);
resolve();
};
log('INFO', name);
Promise.resolve(done)
.then(fn)
.then(() => calls || done())
.catch((err) => {
// log('ERROR', `${name}`);
log('ERROR', ' 🚨 ' + String(err.stack || err.message || err));
resolve();
});
});
for (let i = 0; i < logBuffer.length; i++) log(...logBuffer[i]);
logBuffer = undefined;
stack = stackBefore;
}
};

let logBuffer;

function wrap(obj, method) {
obj[method] = function () {
let out = ' ';
for (let i = 0; i < arguments.length; i++) {
let val = arguments[i];
if (typeof val === 'object' && val) {
val = JSON.stringify(val);
}
if (i) out += ' ';
out += val;
}
if (method !== 'error') out = `\u001b[37m${out}\u001b[0m`;
if (logBuffer) {
logBuffer.push([method.toUpperCase(), out, 1]);
} else {
log(method.toUpperCase(), out);
}
};
}
wrap(console, 'log');
wrap(console, 'info');
wrap(console, 'warn');
wrap(console, 'error');

function log(type, msg) {
if (type === 'ERROR') {
msg = `\x1b[31m${msg}\x1b[39m`;
}
if (type === 'SUCCESS') {
msg = `\x1b[32m${msg}\x1b[39m`;
}
print(Array.from({ length: stack.length }).fill(' ').join('') + msg);
}

function push(op, name, fn, extra) {
if (queue.push([op, name, fn, extra]) === 1) {
setTimeout(process);
}
}

export function describe(name, fn) {
push('describe', name, fn, stack.slice());
}

export function test(name, fn) {
push('test', name, fn, stack.slice());
}

export function expect(subject) {
return new Expect(subject);
}

globalThis.describe = describe;
globalThis.test = test;
globalThis.expect = expect;

const SUBJECT = Symbol.for('subject');
const NEGATED = Symbol.for('negated');
class Expect {
constructor(subject) {
this[SUBJECT] = subject;
this[NEGATED] = false;
}
get not() {
this[NEGATED] = true;
return this;
}
toEqual(value) {
this._result(this[SUBJECT] === value, `to equal ${value}`);
}
toBeGreaterThan(value) {
this._result(this[SUBJECT] > value, `to be greater than ${value}`);
// const negated = this[NEGATED];

// const isOver = subject > value;
// const neg = negated ? ' not' : '';
// const type = isOver !== negated ? 'SUCCESS' : 'ERROR';
// const icon = isOver !== negated ? '✅ ' : '❌ ';
// let msg = `${icon} Expected ${subject}${neg} to be greater than ${value}`;
// if (logBuffer) {
// for (let i = logBuffer.length; i-- > -1; ) {
// if (i < 0 || logBuffer[i][2] === 1) {
// logBuffer.splice(i + 1, 0, [type, msg, 1]);
// break;
// }
// }
// } else {
// log(type, msg);
// }
}
_result(pass, detail) {
const subject = this[SUBJECT];
const negated = this[NEGATED];

const neg = negated ? ' not' : '';
const type = pass !== negated ? 'SUCCESS' : 'ERROR';
const icon = pass !== negated ? '✅ ' : '❌ ';
let msg = `${icon} Expected ${subject}${neg} ${detail}`;
if (logBuffer) {
for (let i = logBuffer.length; i-- > -1; ) {
if (i < 0 || logBuffer[i][2] === 1) {
logBuffer.splice(i + 1, 0, [type, msg, 1]);
break;
}
}
} else {
log(type, msg);
}
}
}