Skip to content

Introduce renderToNodeStream #93

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

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Render JSX and [Preact] components to an HTML string.

Works in Node & the browser, making it useful for universal/isomorphic rendering.

Supports rendering to a Node.js stream using `renderToNodeStream`.

\>\> **[Cute Fox-Related Demo](http://codepen.io/developit/pen/dYZqjE?editors=001)** _(@ CodePen)_ <<


Expand Down Expand Up @@ -90,6 +92,22 @@ app.get('/:fox', (req, res) => {
```


### Render JSX / Preact / Whatever to a Node.js stream

```js
import { renderToNodeStream } from 'preact-render-to-string/stream';
import { h } from 'preact';
/** @jsx h */

let vdom = <div class="foo">content</div>;

let stream = renderToNodeStream(vdom);
stream.on('data', (chunk) => {
console.log(chunk.toString('utf8')); // '<div', ' class="foo"', '>', 'content', '</div>'
});
```


---


Expand Down
27 changes: 18 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
"module": "dist/index.module.js",
"jsnext:main": "dist/index.module.js",
"scripts": {
"build": "npm run -s transpile && npm run -s transpile:jsx && npm run -s copy-typescript-definition",
"build": "npm run -s transpile && npm run -s transpile:jsx && npm run -s transpile:stream && npm run -s copy-typescript-definition",
"transpile": "echo 'export const ENABLE_PRETTY = false;'>env.js && microbundle src/index.js -f es,umd --target web --external preact",
"transpile:jsx": "echo 'export const ENABLE_PRETTY = true;'>env.js && microbundle src/jsx.js -o dist/jsx.js --target web --external none && microbundle dist/jsx.js -o dist/jsx.js -f cjs",
"transpile:stream": "echo 'export const ENABLE_PRETTY = true;'>env.js && microbundle dist/stream.js --external preact,stream -o dist/stream.js -f cjs",
"copy-typescript-definition": "copyfiles -f src/index.d.ts dist",
"test": "eslint src test && mocha --compilers js:babel-register test/**/*.js",
"test": "eslint src test && mocha --compilers js:@babel/register test/**/*.js",
"prepublish": "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"
},
Expand Down Expand Up @@ -40,16 +41,23 @@
},
"babel": {
"presets": [
"env"
[
"@babel/env",
{
"exclude": [
Copy link
Member

Choose a reason for hiding this comment

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

should probably set the target to the minimum node version here

Copy link
Member Author

Choose a reason for hiding this comment

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

Well renderToNodeStream requires node 10. The other parts of this package work fine with 9 and more importantly in the browser

Copy link
Member

Choose a reason for hiding this comment

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

just a bit concerned about the async generator

Copy link
Member Author

Choose a reason for hiding this comment

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

I get that point. The renderToNodeStream should only be used in node anyways, thus an async generator feels good to me :)

I'd propose to disallow async generators in eslint as bundling regenerator would result in a massive bundle bloat. That will make sure we don't use it in modules targeted to browsers

"@babel/plugin-transform-regenerator"
]
}
]
],
"plugins": [
[
"transform-react-jsx",
"@babel/transform-react-jsx",
{
"pragma": "h"
}
],
"transform-object-rest-spread"
"@babel/proposal-object-rest-spread"
]
},
"author": "Jason Miller <[email protected]>",
Expand All @@ -62,10 +70,11 @@
"preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0"
},
"devDependencies": {
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-react-jsx": "^6.24.1",
"babel-preset-env": "^1.7.0",
"babel-register": "^6.26.0",
"@babel/core": "^7.4.4",
"@babel/plugin-proposal-object-rest-spread": "^7.4.4",
"@babel/plugin-transform-react-jsx": "^7.3.0",
"@babel/preset-env": "^7.4.4",
"@babel/register": "^7.4.4",
"chai": "^3.5.0",
"copyfiles": "^1.2.0",
"eslint": "^4.19.1",
Expand Down
264 changes: 264 additions & 0 deletions src/stream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { encodeEntities, styleObjToCss, assign, getChildren } from './util';
import { options, Fragment } from 'preact';
import stream from 'stream';

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

Choose a reason for hiding this comment

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

Note to self: Seems like a memory leak when keeping references to components around in a global variable


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


function PreactReadableStream(vnode, context, opts) {
Copy link
Member

Choose a reason for hiding this comment

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

I think it's safe to use a class here. The current node LTS version supports them natively.

if (opts && opts.pretty) {
throw new Error('pretty is not supported in renderToNodeStream!');
}

stream.Readable.call(this, opts && opts.readable);

this.vnode = vnode;
this.context = context || {};
this.opts = opts || {};
}

PreactReadableStream.prototype = new stream.Readable();
PreactReadableStream.prototype.constructor = PreactReadableStream;
PreactReadableStream.prototype._read = function _read() {
try {
if (!this._generator) {
this._generator = this._generate(this.vnode, this.context, this.opts);
}
else if (this.reading) {
console.warn(new Error('You should not call PreactReadableStream#_read when a read is in progress').stack);
}

this.reading = true;

for (const chunk of this._generator) {
if (!this.push(chunk)) {
this.reading = false;
// high water mark reached, pause the stream until _read is called again...
return;
}
}
}
catch (e) {
this.emit('error', e);
this.push(null);
return;
}

// end the stream
this.push(null);
};

PreactReadableStream.prototype._generate = function *_generate(vnode, context, opts, inner, isSvgMode, selectValue) {
if (vnode==null || typeof vnode==='boolean') {
yield '';
return;
}

let nodeName = vnode.type,
props = vnode.props,
isComponent = false;
context = context || {};
opts = opts || {};

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

// components
if (typeof nodeName==='function') {
isComponent = true;
if (opts.shallow && (inner || opts.renderRootComponent===false)) {
nodeName = getComponentName(nodeName);
}
else if (nodeName===Fragment) {
let children = [];
getChildren(children, vnode.props.children);

for (let i = 0; i < children.length; i++) {
for (const chunk of this._generate(children[i], context, opts, opts.shallowHighOrder!==false, isSvgMode, selectValue)) {
yield chunk;
}
}

return;
}
else {
let c = vnode.__c = { __v: vnode, context, props: vnode.props };
if (options.render) options.render(vnode);

let renderedVNode;

if (!nodeName.prototype || typeof nodeName.prototype.render!=='function') {
// 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._defaultValue) : context;
Copy link
Contributor

Choose a reason for hiding this comment

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

Hello! Looks like the use of _defaultValue might be a bug (#130); changing it to __ should fix it.


// stateless functional components
renderedVNode = nodeName.call(vnode.__c, props, cctx);
}
else {
// class-based components
// c = new nodeName(props, context);
c = vnode.__c = new nodeName(props, context);
c.__v = vnode;
// turn off stateful re-rendering:
c._dirty = c.__d = true;
c.props = props;
c.context = context;
if (nodeName.getDerivedStateFromProps) c.state = assign(assign({}, c.state), nodeName.getDerivedStateFromProps(c.props, c.state));
else if (c.componentWillMount) c.componentWillMount();

renderedVNode = c.render(c.props, c.state || {}, c.context);
}

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

for (const chunk of this._generate(renderedVNode, context, opts, opts.shallowHighOrder!==false, isSvgMode, selectValue)) {
yield chunk;
}

return;
}
}

// render JSX to HTML
let s = '', 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') continue;

if (name.match(/[\s\n\\/='"\0<>]/)) continue;

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

if (name==='className') {
if (props.class) continue;
name = 'class';
}
else if (isSvgMode && name.match(/^xlink:?./)) {
name = name.toLowerCase().replace(/^xlink:?/, 'xlink:');
}

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

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

if (name==='dangerouslySetInnerHTML') {
html = v && v.__html;
}
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 += ' ' + name;
continue;
}
}

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

let isVoid = String(nodeName).match(VOID_ELEMENTS);

s = `<${nodeName}${s}`;
if (String(nodeName).match(/[\s\n\\/='"\0<>]/)) throw s;

yield s;

if (html) {
yield `>${html}</${nodeName}>`;
return;
}

let didCloseOpeningTag = false;

let children = [];
if (props && getChildren(children, props.children).length) {
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;

for (const chunk of this._generate(child, context, opts, true, childSvgMode, selectValue)) {
if (chunk) {
if (!didCloseOpeningTag) {
didCloseOpeningTag = true;
yield '>';
}

yield chunk;
}
}
}
}
}

yield didCloseOpeningTag
? `</${nodeName}>`
: `${isVoid || opts.xml ? '/>' : `></${nodeName}>`}`;
};

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;
}


export default function renderToNodeStream(vnode, context, opts) {
return new PreactReadableStream(vnode, context, opts);
}
Loading