Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4dd09e0
perf: skip redundant await for synchronous async renders
JoviDeCroock Jul 10, 2026
e2cfd1e
perf: use string scans for entity fast path
JoviDeCroock Jul 10, 2026
b15a79f
perf: avoid generic lookups in attribute serialization
JoviDeCroock Jul 10, 2026
fcf889c
perf: reuse component prototype during type detection
JoviDeCroock Jul 10, 2026
20a03c3
perf: scan short strings directly for entities
JoviDeCroock Jul 10, 2026
06626a2
perf: parse namespace attribute prefixes directly
JoviDeCroock Jul 10, 2026
4534b6a
perf: serialize numeric element children directly
JoviDeCroock Jul 10, 2026
a59961b
perf: parse JSX namespace attributes directly
JoviDeCroock Jul 10, 2026
f40dde6
perf: remove quadratic suspension cleanup
JoviDeCroock Jul 10, 2026
be859b5
perf: skip abort race for non-abortable streams
JoviDeCroock Jul 10, 2026
79fe5ad
fix: benchmark async rendering
JoviDeCroock Aug 5, 2026
4b121c8
perf: specialize primitive array children in one pass
JoviDeCroock Aug 5, 2026
94562df
perf: route empty strings through text rendering
JoviDeCroock Aug 5, 2026
9808143
perf: avoid slicing namespace attribute prefixes
JoviDeCroock Aug 5, 2026
301276a
perf: skip function values in child arrays
JoviDeCroock Aug 5, 2026
9e58eca
perf: switch on self-closing element names
JoviDeCroock Aug 5, 2026
5c9e4da
perf: scan async render results for promises directly
JoviDeCroock Aug 5, 2026
a389692
perf: wrap suspense array results in one pass
JoviDeCroock Aug 5, 2026
e02f4c5
perf: flatten async render results directly
JoviDeCroock Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion benchmarks/async.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const lazies = new Array(600).fill(600).map(() =>
)
);
function PassThrough(props) {
const Lazy = lazies(props.id);
const Lazy = lazies[props.id];
return <Lazy {...props} />;
}

Expand Down
36 changes: 24 additions & 12 deletions benchmarks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,38 @@ import StackApp from './stack';
import { App as IsomorphicSearchResults } from './isomorphic-ui/search-results/index';
import { App as ColorPicker } from './isomorphic-ui/color-picker';

function suite(name, Root) {
function syncSuite(name, Root) {
return new Suite(name)
.add('baseline', () => renderToStringAsyncBaseline(<Root />))
.add('current', () => renderToStringAsync(<Root />))
.add('baseline', () => renderToStringBaseline(<Root />))
.add('current', () => renderToString(<Root />))
.run();
}

function asyncSuite(name, Root) {
return new Suite(name)
.add('baseline', () => renderToStringBaseline(<Root />))
.add('current', () => renderToString(<Root />))
.run();
const suite = new Suite(name);
suite.suite.add(
'baseline',
function (deferred) {
renderToStringAsyncBaseline(<Root />).then(() => deferred.resolve());
},
{ defer: true }
);
suite.suite.add(
'current',
function (deferred) {
renderToStringAsync(<Root />).then(() => deferred.resolve());
},
{ defer: true }
);
return suite.run();
}

(async () => {
await suite('Text', TextApp);
await suite('SearchResults', IsomorphicSearchResults);
await suite('ColorPicker', ColorPicker);
await suite('Stack Depth', StackApp);
await syncSuite('Text', TextApp);
await syncSuite('SearchResults', IsomorphicSearchResults);
await syncSuite('ColorPicker', ColorPicker);
await syncSuite('Stack Depth', StackApp);

const { App: Async } = await import('./async.js');
const { default: Async } = await import('./async.js');
await asyncSuite('async', Async);
})();
153 changes: 100 additions & 53 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import {
encodeEntities,
styleObjToCss,
UNSAFE_NAME,
NAMESPACE_REPLACE_REGEX,
HTML_LOWER_CASE,
HTML_ENUMERATED,
SVG_CAMEL_CASE,
createComponent,
setDirty,
Expand Down Expand Up @@ -45,15 +43,31 @@ function wrapWithSuspenseMarkers(result) {
if (typeof result === 'string') {
return BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR;
} else if (isArray(result)) {
result.unshift(BEGIN_SUSPENSE_DENOMINATOR);
result.push(END_SUSPENSE_DENOMINATOR);
return result;
return [BEGIN_SUSPENSE_DENOMINATOR, ...result, END_SUSPENSE_DENOMINATOR];
} else if (result && typeof result.then === 'function') {
return result.then(wrapWithSuspenseMarkers);
}
return BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR;
}

function hasPromise(values) {
for (let i = 0; i < values.length; i++) {
let value = values[i];
if (value && typeof value.then === 'function') return true;
}
return false;
}

function flatten(values) {
let result = [];
for (let i = 0; i < values.length; i++) {
let value = values[i];
if (isArray(value)) result.push(...value);
else result.push(value);
}
return result;
}

/**
* Capture the Preact option hooks used by a render so suspended subtrees don't
* observe hooks installed by another render before they resume.
Expand Down Expand Up @@ -151,7 +165,7 @@ export async function renderToStringAsync(vnode, context) {
const hooks = captureHooks();

try {
const rendered = await withSkipEffects(() => {
let rendered = withSkipEffects(() => {
const parent = h(Fragment, null);
parent[CHILDREN] = [vnode];
if (hooks.rootHook) {
Expand All @@ -170,18 +184,17 @@ export async function renderToStringAsync(vnode, context) {
);
});

if (typeof rendered !== 'string' && !isArray(rendered)) {
rendered = await rendered;
}

if (isArray(rendered)) {
let count = 0;
let resolved = rendered;

// Resolving nested Promises with a maximum depth of 25
while (
resolved.some(
(element) => element && typeof element.then === 'function'
) &&
count++ < 25
) {
resolved = (await Promise.all(resolved)).flat();
while (hasPromise(resolved) && count++ < 25) {
resolved = flatten(await Promise.all(resolved));
}

return resolved.join(EMPTY_STR);
Expand Down Expand Up @@ -278,12 +291,7 @@ function _renderToString(
hooks
) {
// Ignore non-rendered VNodes/values
if (
vnode == null ||
vnode === true ||
vnode === false ||
vnode === EMPTY_STR
) {
if (vnode == null || vnode === true || vnode === false) {
return EMPTY_STR;
}

Expand All @@ -304,16 +312,23 @@ function _renderToString(
let child = vnode[i];
if (child == null || typeof child == 'boolean') continue;

const childRender = _renderToString(
child,
context,
isSvgMode,
selectValue,
parent,
asyncMode,
renderer,
hooks
);
const childType = typeof child;
if (childType == 'function') continue;
const childRender =
childType == 'string'
? encodeEntities(child)
: childType == 'number'
? child + EMPTY_STR
: _renderToString(
child,
context,
isSvgMode,
selectValue,
parent,
asyncMode,
renderer,
hooks
);

if (typeof childRender == 'string') {
rendered = rendered + childRender;
Expand Down Expand Up @@ -408,8 +423,8 @@ function _renderToString(
cctx = provider ? provider.props.value : contextType.__;
}

let isClassComponent =
type.prototype && typeof type.prototype.render == 'function';
let prototype = type.prototype;
let isClassComponent = prototype && typeof prototype.render == 'function';
if (isClassComponent) {
rendered = /**#__NOINLINE__**/ renderClassComponent(vnode, cctx, hooks);
component = vnode[COMPONENT];
Expand Down Expand Up @@ -695,12 +710,19 @@ function _renderToString(
break;

default: {
let namespaceLength;
if (UNSAFE_NAME.test(name)) {
continue;
} else if (NAMESPACE_REPLACE_REGEX.test(name)) {
name = name.replace(NAMESPACE_REPLACE_REGEX, '$1:$2').toLowerCase();
} else if (
(name[4] === '-' || HTML_ENUMERATED.has(name)) &&
name[0] === 'x' &&
(namespaceLength = getNamespaceLength(name)) !== 0
) {
name =
name.slice(0, namespaceLength) +
':' +
name.slice(namespaceLength).toLowerCase();
} else if (
(name[4] === '-' || name === 'draggable' || name === 'spellcheck') &&
v != null
) {
// serialize boolean aria-xyz or enumerated attribute values as strings
Expand Down Expand Up @@ -745,6 +767,8 @@ function _renderToString(
} else if (typeof children === 'string') {
// single text child
html = encodeEntities(children);
} else if (typeof children === 'number') {
html = children + EMPTY_STR;
} else if (children != null && children !== false && children !== true) {
// recurse into this element VNode's children
let childSvgMode =
Expand All @@ -766,7 +790,7 @@ function _renderToString(
if (hooks.unmountHook) hooks.unmountHook(vnode);

// Emit self-closing tag for empty void elements:
if (!html && SELF_CLOSING.has(type)) {
if (!html && isSelfClosing(type)) {
return s + '/>';
}

Expand All @@ -778,24 +802,29 @@ function _renderToString(
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'
]);
function isSelfClosing(type) {
switch (type) {
case 'area':
case 'base':
case 'br':
case 'col':
case 'command':
case 'embed':
case 'hr':
case 'img':
case 'input':
case 'keygen':
case 'link':
case 'meta':
case 'param':
case 'source':
case 'track':
case 'wbr':
return true;
default:
return false;
}
}

export default renderToString;
export const render = renderToString;
Expand All @@ -809,3 +838,21 @@ function isSignal(x) {
'value' in x
);
}

function getNamespaceLength(name) {
let length = name[1] === 'l' || name[3] === 'n' ? 5 : 3;
let char = name.charCodeAt(length);
return char >= 65 &&
char <= 90 &&
(length === 3
? name.charCodeAt(1) === 109 && name.charCodeAt(2) === 108
: name[1] === 'l'
? name.charCodeAt(2) === 105 &&
name.charCodeAt(3) === 110 &&
name.charCodeAt(4) === 107
: name.charCodeAt(1) === 109 &&
name.charCodeAt(2) === 108 &&
name.charCodeAt(4) === 115)
? length
: 0;
}
28 changes: 12 additions & 16 deletions src/lib/chunked.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,10 @@ function getDocumentClosingTagsIndex(html) {
}

async function forkPromises(renderer) {
if (renderer.suspended.length > 0) {
const suspensions = [...renderer.suspended];
while (renderer.suspended.length > 0) {
const length = renderer.suspended.length;
await Promise.all(renderer.suspended.map((s) => s.promise));
renderer.suspended = renderer.suspended.filter(
(s) => !suspensions.includes(s)
);
await forkPromises(renderer);
renderer.suspended.splice(0, length);
}
}

Expand All @@ -85,16 +82,8 @@ function handleError(error, vnode, renderChild) {

const id = vnode.__v;
const found = this.suspended.find((x) => x.id === id);
const race = new Deferred();

const abortSignal = this.abortSignal;
if (abortSignal) {
// @ts-ignore 2554 - implicit undefined arg
if (abortSignal.aborted) race.resolve();
else abortSignal.addEventListener('abort', race.resolve);
}

const promise = error.then(
let promise = error.then(
() => {
if (abortSignal && abortSignal.aborted) return;
const child = renderChild(vnode.props.children, vnode);
Expand All @@ -104,11 +93,18 @@ function handleError(error, vnode, renderChild) {
// to attempt to recover during hydration
this.onError
);
if (abortSignal) {
const race = new Deferred();
// @ts-ignore 2554 - implicit undefined arg
if (abortSignal.aborted) race.resolve();
else abortSignal.addEventListener('abort', race.resolve);
promise = Promise.race([promise, race.promise]);
}

this.suspended.push({
id,
vnode,
promise: Promise.race([promise, race.promise])
promise
});

const fallback = renderChild(vnode.props.fallback);
Expand Down
19 changes: 14 additions & 5 deletions src/lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,25 @@ export function isDirty(component) {
return component[DIRTY] === true;
}

// DOM properties that should NOT have "px" added when numeric
const ENCODED_ENTITIES = /["&<]/;

/** @param {string} str */
export function encodeEntities(str) {
// Skip all work for strings with no entities needing encoding:
if (str.length === 0 || ENCODED_ENTITIES.test(str) === false) return str;
let i = 0;
if (str.length < 8) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 8?

@JoviDeCroock JoviDeCroock Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried all numbers and larger than 8 seemed to be slower in V8 😂

Use a character-code loop for strings shorter than eight characters and retain native indexOf scans for longer strings. Escaped short strings continue encoding from the first entity found by the fast-path scan.

for (; i < str.length; i++) {
const char = str.charCodeAt(i);
if (char === 34 || char === 38 || char === 60) break;
}
if (i === str.length) return str;
} else if (
str.indexOf('"') === -1 &&
str.indexOf('&') === -1 &&
str.indexOf('<') === -1
) {
return str;
}

let last = 0,
i = 0,
out = '',
ch = '';

Expand Down
Loading
Loading