Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: preactjs/preact-render-to-string
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v5.2.2
Choose a base ref
...
head repository: preactjs/preact-render-to-string
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 2d0cd1decb7e24fdf6facc0d169c7f893e1827eb
Choose a head ref
  • 7 commits
  • 19 files changed
  • 5 contributors

Commits on Aug 16, 2022

  1. Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    2d5ca74 View commit details

Commits on Aug 17, 2022

  1. Copy the full SHA
    78f365f View commit details
  2. Revert "add parent and children for useId"

    This reverts commit 78f365f.
    JoviDeCroock committed Aug 17, 2022
    Copy the full SHA
    38036d6 View commit details

Commits on Aug 19, 2022

  1. Copy the full SHA
    fa53a96 View commit details
  2. Fix options hook calling order (#238)

    * Fix options hook calling order (diff -> children -> diffed), add comments
    
    * Create honest-kangaroos-mix.md
    
    * update options hooks tests to reflect correct calling order
    developit authored Aug 19, 2022

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    7cdf4d6 View commit details
  3. Copy the full SHA
    010fb72 View commit details

Commits on Aug 24, 2022

  1. Version Packages (#236)

    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
    github-actions[bot] and github-actions[bot] authored Aug 24, 2022

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
    Copy the full SHA
    2d0cd1d View commit details
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# preact-render-to-string

## 5.2.3

### Patch Changes

- [#232](https://github.com/preactjs/preact-render-to-string/pull/232) [`2d5ca74`](https://github.com/preactjs/preact-render-to-string/commit/2d5ca74646f2f9f2e9ddeb20ed9c3fc47171c264) Thanks [@JoviDeCroock](https://github.com/JoviDeCroock)! - Performance enhancements

* [#238](https://github.com/preactjs/preact-render-to-string/pull/238) [`7cdf4d6`](https://github.com/preactjs/preact-render-to-string/commit/7cdf4d67abba622124902e53e016affbbebc647e) Thanks [@developit](https://github.com/developit)! - Fix the order of invocation for the "before diff" (`__b`) and "diffed" [options hooks](https://preactjs.com/guide/v10/options/).

## 5.2.2

### Patch Changes
7 changes: 5 additions & 2 deletions benchmarks/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { h } from 'preact';
import Suite from 'benchmarkjs-pretty';
import renderToStringBaseline from './lib/render-to-string';
import renderToString from '../src/index';
// import renderToString from '../src/index';
import renderToString from '../dist/index.mjs';
import TextApp from './text';
// import StackApp from './stack';
import { App as IsomorphicSearchResults } from './isomorphic-ui-search-results';
import { App as IsomorphicSearchResults } from './isomorphic-ui/search-results/index';
import { App as ColorPicker } from './isomorphic-ui/color-picker';

function suite(name, Root) {
return new Suite(name)
@@ -16,6 +18,7 @@ function suite(name, Root) {
(async () => {
await suite('Text', TextApp);
await suite('SearchResults', IsomorphicSearchResults);
await suite('ColorPicker', ColorPicker);
// TODO: Enable this once we switched away from recursion
// await suite('Stack Depth', StackApp);
})();
667 changes: 667 additions & 0 deletions benchmarks/isomorphic-ui/_colors.js

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions benchmarks/isomorphic-ui/color-picker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import colors from './_colors.js';

export function App({ onMount }) {
const [selectedColorIndex, setSelectedColorIndex] = useState(0);
const selectedColor = colors[selectedColorIndex];

useEffect(() => {
if (onMount) {
onMount(setSelectedColorIndex);
}
if (typeof window !== 'undefined') window.onMount();
}, []);

return (
<div className="colors">
<h1>Choose your favorite color:</h1>
<div className="colors">
{colors.length ? (
<ul>
{colors.map((color, i) => (
<li
className={
'color' + (selectedColorIndex === i ? ' selected' : '')
}
key={i}
style={{
backgroundColor: color.hex
}}
onClick={() => setSelectedColorIndex(i)}
>
{color.name}
</li>
))}
</ul>
) : (
<div>No colors!</div>
)}
</div>
<div>
You chose:
<div className="chosen-color">{selectedColor.name}</div>
</div>
</div>
);
}
6 changes: 5 additions & 1 deletion benchmarks/lib/benchmark-lite.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export default class Suite {
constructor(name, { iterations = 10, timeLimit = 5000 } = {}) {
this.name = name;
@@ -9,12 +11,14 @@ export default class Suite {
this.tests.push({ name, executor });
return this;
}
run() {
async run() {
console.log(` ${this.name}:`);
const results = [];
let fastest = 0;
for (const test of this.tests) {
await sleep(50);
for (let i = 0; i < 5; i++) test.executor(i);
await sleep(10);
const result = this.runOne(test);
if (result.hz > fastest) fastest = result.hz;
results.push({ ...test, ...result });
546 changes: 359 additions & 187 deletions benchmarks/lib/render-to-string.js

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions package-lock.json
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "preact-render-to-string",
"amdName": "preactRenderToString",
"version": "5.2.2",
"version": "5.2.3",
"description": "Render JSX to an HTML string, with support for Preact components.",
"main": "dist/index.js",
"umd:main": "dist/index.js",
@@ -139,5 +139,10 @@
"hooks": {
"pre-commit": "lint-staged"
}
},
"mangle": {
"compress": {
"reduce_funcs": false
}
}
}
14 changes: 14 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Options hooks
export const DIFF = '__b';
export const RENDER = '__r';
export const DIFFED = 'diffed';
export const COMMIT = '__c';
export const SKIP_EFFECTS = '__s';

// VNode properties
export const COMPONENT = '__c';

// Component properties
export const VNODE = '__v';
export const DIRTY = '__d';
export const NEXT_STATE = '__s';
797 changes: 202 additions & 595 deletions src/index.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/jsx.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import './polyfills';
import renderToString from './index';
import { indent, encodeEntities, assign } from './util';
import { indent, encodeEntities } from './util';
import prettyFormat from 'pretty-format';

// we have to patch in Array support, Possible issue in npm.im/pretty-format
@@ -68,7 +68,7 @@ let defaultOpts = {
};

function renderToJsxString(vnode, context, opts, inner) {
opts = assign(assign({}, defaultOpts), opts || {});
opts = Object.assign({}, defaultOpts, opts || {});
return renderToString(vnode, context, opts, inner);
}

385 changes: 385 additions & 0 deletions src/pretty.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,385 @@
import {
encodeEntities,
indent,
isLargeString,
styleObjToCss,
getChildren,
createComponent,
getContext,
UNSAFE_NAME,
XLINK,
VOID_ELEMENTS
} from './util';
import { options, Fragment } from 'preact';

// components without names, kept as a hash for later comparison to return consistent UnnamedComponentXX names.
const UNNAMED = [];

export function _renderToStringPretty(
vnode,
context,
opts,
inner,
isSvgMode,
selectValue
) {
if (vnode == null || typeof vnode === 'boolean') {
return '';
}

// #text nodes
if (typeof vnode !== 'object') {
return encodeEntities(vnode);
}

let pretty = opts.pretty,
indentChar = pretty && typeof pretty === 'string' ? pretty : '\t';

if (Array.isArray(vnode)) {
let rendered = '';
for (let i = 0; i < vnode.length; i++) {
if (pretty && i > 0) rendered = rendered + '\n';
rendered =
rendered +
_renderToStringPretty(
vnode[i],
context,
opts,
inner,
isSvgMode,
selectValue
);
}
return rendered;
}

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

// components
if (typeof nodeName === 'function') {
isComponent = true;
if (opts.shallow && (inner || opts.renderRootComponent === false)) {
nodeName = getComponentName(nodeName);
} else if (nodeName === Fragment) {
const children = [];
getChildren(children, vnode.props.children);
return _renderToStringPretty(
children,
context,
opts,
opts.shallowHighOrder !== false,
isSvgMode,
selectValue
);
} else {
let rendered;

let c = (vnode.__c = createComponent(vnode, context));

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

// options._render
let renderHook = options.__r;

if (
!nodeName.prototype ||
typeof nodeName.prototype.render !== 'function'
) {
let cctx = getContext(nodeName, context);

// 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 (c.__d && count++ < 25) {
c.__d = false;

if (renderHook) renderHook(vnode);

// stateless functional components
rendered = nodeName.call(vnode.__c, props, cctx);
}
} else {
let cctx = getContext(nodeName, context);

// c = new nodeName(props, 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 (nodeName.getDerivedStateFromProps)
c.state = Object.assign(
{},
c.state,
nodeName.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._nextState !== c.state
? c._nextState
: c.__s !== c.state
? c.__s
: c.state;
}

if (renderHook) renderHook(vnode);

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

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

if (options.diffed) options.diffed(vnode);
return _renderToStringPretty(
rendered,
context,
opts,
opts.shallowHighOrder !== false,
isSvgMode,
selectValue
);
}
}

// render JSX to HTML
let s = '<' + nodeName,
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 && 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 && opts.allAttributes) &&
(name === 'key' ||
name === 'ref' ||
name === '__self' ||
name === '__source')
)
continue;

if (name === 'defaultValue') {
name = 'value';
} else if (name === 'defaultChecked') {
name = 'checked';
} else if (name === 'defaultSelected') {
name = 'selected';
} else if (name === 'className') {
if (typeof props.class !== 'undefined') continue;
name = 'class';
} else if (isSvgMode && XLINK.test(name)) {
name = name.toLowerCase().replace(/^xlink:?/, 'xlink:');
}

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 (
// If we're looking at an <option> and it's the currently selected one
nodeName === 'option' &&
selectValue == v &&
// and the <option> doesn't already have a selected attribute on it
typeof props.selected === 'undefined'
) {
s = s + ` selected`;
}
}
s = s + ` ${name}="${encodeEntities(v)}"`;
}
}
}

// account for >1 multiline attribute
if (pretty) {
let sub = s.replace(/\n\s*/, ' ');
if (sub !== s && !~sub.indexOf('\n')) s = sub;
else if (pretty && ~s.indexOf('\n')) s = s + '\n';
}

s = s + '>';

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

let isVoid =
VOID_ELEMENTS.test(nodeName) ||
(opts.voidElements && opts.voidElements.test(nodeName));
let pieces = [];

let children;
if (html) {
// if multiline, indent.
if (pretty && isLargeString(html)) {
html = '\n' + indentChar + indent(html, indentChar);
}
s = s + html;
} else if (
propChildren != null &&
getChildren((children = []), propChildren).length
) {
let hasLarge = pretty && ~s.indexOf('\n');
let lastWasText = false;

for (let i = 0; i < children.length; i++) {
let child = children[i];

if (child != null && child !== false) {
let childSvgMode =
nodeName === 'svg'
? true
: nodeName === 'foreignObject'
? false
: isSvgMode,
ret = _renderToStringPretty(
child,
context,
opts,
true,
childSvgMode,
selectValue
);

if (pretty && !hasLarge && isLargeString(ret)) hasLarge = true;

// Skip if we received an empty string
if (ret) {
if (pretty) {
let isText = ret.length > 0 && ret[0] != '<';

// We merge adjacent text nodes, otherwise each piece would be printed
// on a new line.
if (lastWasText && isText) {
pieces[pieces.length - 1] += ret;
} else {
pieces.push(ret);
}

lastWasText = isText;
} else {
pieces.push(ret);
}
}
}
}
if (pretty && hasLarge) {
for (let i = pieces.length; i--; ) {
pieces[i] = '\n' + indentChar + indent(pieces[i], indentChar);
}
}
}

if (pieces.length || html) {
s = s + pieces.join('');
} else if (opts && opts.xml) {
return s.substring(0, s.length - 1) + ' />';
}

if (isVoid && !children && !html) {
s = s.replace(/>$/, ' />');
} else {
if (pretty && ~s.indexOf('\n')) s = s + '\n';
s = s + `</${nodeName}>`;
}

return s;
}

function getComponentName(component) {
return (
component.displayName ||
(component !== Function && component.name) ||
getFallbackComponentName(component)
);
}

function getFallbackComponentName(component) {
let str = Function.prototype.toString.call(component),
name = (str.match(/^\s*function\s+([^( ]+)/) || '')[1];
if (!name) {
// search for an existing indexed name for the given component:
let index = -1;
for (let i = UNNAMED.length; i--; ) {
if (UNNAMED[i] === component) {
index = i;
break;
}
}
// not found, create a new indexed name:
if (index < 0) {
index = UNNAMED.push(component) - 1;
}
name = `UnnamedComponent${index}`;
}
return name;
}
80 changes: 55 additions & 25 deletions src/util.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// 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 const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
export const UNSAFE_NAME = /[\s\n\\/='"\0<>]/;
export const XLINK = /^xlink:?./;

const ENCODED_ENTITIES = /[&<>"]/;
const ENCODED_ENTITIES = /["&<]/;

export function encodeEntities(str) {
// Ensure we're always parsing and returning a string:
@@ -16,21 +19,28 @@ export function encodeEntities(str) {
ch = '';

// Seek forward in str until the next entity char:
for (; i<str.length; i++) {
for (; i < str.length; i++) {
switch (str.charCodeAt(i)) {
case 60: ch = '&lt;'; break;
case 62: ch = '&gt;'; break;
case 34: ch = '&quot;'; break;
case 38: ch = '&amp;'; break;
default: continue;
case 34:
ch = '&quot;';
break;
case 38:
ch = '&amp;';
break;
case 60:
ch = '&lt;';
break;
default:
continue;
}
// Append skipped/buffered characters and the encoded entity:
if (i > last) out += str.slice(last, i);
if (i !== last) out += str.slice(last, i);
out += ch;
// Start the next seek/buffer after the entity's offset:
last = i + 1;
}
return out + str.slice(last, i);
if (i !== last) out += str.slice(last, i);
return out;
}

export let indent = (s, char) =>
@@ -43,6 +53,7 @@ export let isLargeString = (s, length, ignoreLines) =>

const JS_TO_CSS = {};

const CSS_REGEX = /([A-Z])/g;
// Convert an Object style to a CSSText string
export function styleObjToCss(s) {
let str = '';
@@ -55,29 +66,18 @@ export function styleObjToCss(s) {
prop[0] == '-'
? prop
: JS_TO_CSS[prop] ||
(JS_TO_CSS[prop] = prop.replace(/([A-Z])/g, '-$1').toLowerCase());
str += ': ';
str += val;
(JS_TO_CSS[prop] = prop.replace(CSS_REGEX, '-$1').toLowerCase());

if (typeof val === 'number' && IS_NON_DIMENSIONAL.test(prop) === false) {
str += 'px';
str = str + ': ' + val + 'px;';
} else {
str = str + ': ' + val + ';';
}
str += ';';
}
}
return str || undefined;
}

/**
* Copy all properties from `props` onto `obj`.
* @param {object} obj Object onto which properties should be copied.
* @param {object} props Object from which to copy properties.
* @returns {object}
* @private
*/
export function assign(obj, props) {
return Object.assign(obj, props);
}

/**
* Get flattened children from the children prop
* @param {Array} accumulator
@@ -93,3 +93,33 @@ export function getChildren(accumulator, children) {
}
return accumulator;
}

function markAsDirty() {
this.__d = true;
}

export function createComponent(vnode, context) {
return {
__v: vnode,
context,
props: vnode.props,
// silently drop state updates
setState: markAsDirty,
forceUpdate: markAsDirty,
__d: true,
// hooks
__h: []
};
}

// Necessary for createContext api. Setting this property will pass
// the context value as `this.context` just for this component.
export function getContext(nodeName, context) {
let cxType = nodeName.contextType;
let provider = cxType && context[cxType.__c];
return cxType != null
? provider
? provider.props.value
: cxType.__
: context;
}
46 changes: 27 additions & 19 deletions test/render.test.js
Original file line number Diff line number Diff line change
@@ -177,7 +177,7 @@ describe('render', () => {

it('should encode entities', () => {
let rendered = render(<div a={'"<>&'}>{'"<>&'}</div>),
expected = `<div a="&quot;&lt;&gt;&amp;">&quot;&lt;&gt;&amp;</div>`;
expected = `<div a="&quot;&lt;>&amp;">&quot;&lt;>&amp;</div>`;

expect(rendered).to.equal(expected);
});
@@ -871,7 +871,7 @@ describe('render', () => {
});

describe('state locking', () => {
it('should set _dirty and __d to true', () => {
it('should set __d (_dirty) to true', () => {
let inst;
class Foo extends Component {
constructor(props, context) {
@@ -885,7 +885,7 @@ describe('render', () => {

expect(render(<Foo />)).to.equal('<div></div>');

expect(inst).to.have.property('_dirty', true);
// expect(inst).to.have.property('_dirty', true);
expect(inst).to.have.property('__d', true);
});

@@ -1154,30 +1154,38 @@ describe('render', () => {
if (oldCommit) oldCommit(...args);
};

function Component1({ children }) {
return children;
}
const outer = <Outer />;
const inner = <Inner />;
const div = <div />;

function Component2() {
return <div />;
function Outer() {
return inner;
}

const vnode2 = <Component2>1</Component2>;
const vnode1 = <Component1>{vnode2}</Component1>;
function Inner() {
return div;
}

render(vnode1);
render(outer);

expect(calls).to.deep.equal([
['_diff', [vnode1]],
['_render', [vnode1]],
['diffed', [vnode1]],
['_diff', [vnode2]],
['_render', [vnode2]],
['diffed', [vnode2]],
['_commit', [vnode1, []]]
['_diff', [outer]], // before diff
['_render', [outer]], // render attempt

['_diff', [inner]], // before diff
['_render', [inner]], // render attempt

// innermost <div>
['_diff', [div]], // before diff
['diffed', [div]], // after diff

['diffed', [inner]], // after diff
['diffed', [outer]], // after diff

['_commit', [outer, []]] // commit root
]);

expect(calls.length).to.equal(7);
expect(calls.length).to.equal(9);

options.__b = oldDiff;
options.__r = oldRender;