Skip to content
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

Simple suspense renderer 2024 #333

Merged
merged 10 commits into from
Feb 20, 2024
Merged
7 changes: 7 additions & 0 deletions .changeset/pink-gifts-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"preact-render-to-string": minor
---

Allow prepass like behavior where a Promise
will be awaited and then continued, this is done with
the new `renderToStringAsync` export
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"copy-typescript-definition": "copyfiles -f src/*.d.ts dist",
"test": "eslint src test && tsc && npm run test:mocha && npm run test:mocha:compat && npm run test:mocha:debug && npm run bench",
"test:mocha": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/*.test.js",
"test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/compat/index.test.js'",
"test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/compat/*.test.js'",
"test:mocha:debug": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/debug/index.test.js'",
"format": "prettier src/**/*.{d.ts,js} test/**/*.js --write",
"prepublishOnly": "npm run build",
Expand Down
14 changes: 12 additions & 2 deletions src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { VNode } from 'preact';

export default function renderToString<P = {}>(vnode: VNode<P>, context?: any): string;
export default function renderToString<P = {}>(
vnode: VNode<P>,
context?: any
): string;

export function render<P = {}>(vnode: VNode<P>, context?: any): string;
export function renderToString<P = {}>(vnode: VNode<P>, context?: any): string;
export function renderToStaticMarkup<P = {}>(vnode: VNode<P>, context?: any): string;
export function renderToStringAsync<P = {}>(
vnode: VNode<P>,
context?: any
): string | Promise<string>;
export function renderToStaticMarkup<P = {}>(
vnode: VNode<P>,
context?: any
): string;
190 changes: 166 additions & 24 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,74 @@ export function renderToString(vnode, context) {
context || EMPTY_OBJ,
false,
undefined,
parent
parent,
false
);
} catch (e) {
if (e.then) {
throw new Error('Use "renderToStringAsync" for suspenseful rendering.');
}
Comment on lines +67 to +69
Copy link
Member

@rschristian rschristian Mar 18, 2024

Choose a reason for hiding this comment

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

Only just noticed, this is a breaking change for all preact-iso users... do we want to downgrade this to a warning message instead of throwing an error, or just accept the breaking change in a minor?


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

/**
* 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 async function renderToStringAsync(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 {
const rendered = _renderToString(
vnode,
context || EMPTY_OBJ,
false,
undefined,
parent,
true
);

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

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

return resolved.join('');
}

return rendered;
} finally {
// options._commit, we don't schedule any effects in this library right now,
// so we can pass an empty queue to this hook.
Expand Down Expand Up @@ -137,9 +203,17 @@ function renderClassComponent(vnode, context) {
* @param {boolean} isSvgMode
* @param {any} selectValue
* @param {VNode} parent
* @returns {string}
* @param {boolean} asyncMode
* @returns {string | Promise<string> | (string | Promise<string>)[]}
*/
function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
function _renderToString(
vnode,
context,
isSvgMode,
selectValue,
parent,
asyncMode
) {
// Ignore non-rendered VNodes/values
if (vnode == null || vnode === true || vnode === false || vnode === '') {
return '';
Expand All @@ -153,16 +227,44 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {

// Recurse into children / Arrays
if (isArray(vnode)) {
let rendered = '';
let rendered = '',
renderArray;
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);
const childRender = _renderToString(
child,
context,
isSvgMode,
selectValue,
parent,
asyncMode
);

if (typeof childRender === 'string') {
rendered += childRender;
} else {
renderArray = renderArray || [];

if (rendered) renderArray.push(rendered);

rendered = '';

if (Array.isArray(childRender)) {
renderArray.push(...childRender);
} else {
renderArray.push(childRender);
}
}
}

if (renderArray) {
if (rendered) renderArray.push(rendered);
return renderArray;
}

return rendered;
}

Expand Down Expand Up @@ -202,7 +304,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
context,
isSvgMode,
selectValue,
vnode
vnode,
asyncMode
);
} else {
// Values are pre-escaped by the JSX transform
Expand Down Expand Up @@ -282,7 +385,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
context,
isSvgMode,
selectValue,
vnode
vnode,
asyncMode
);
return str;
} catch (err) {
Expand Down Expand Up @@ -313,7 +417,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
context,
isSvgMode,
selectValue,
vnode
vnode,
asyncMode
);
}

Expand All @@ -333,20 +438,44 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
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;
const renderChildren = () =>
_renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode
);

try {
// Recurse into children before invoking the after-diff hook
const str = renderChildren();

if (afterDiff) afterDiff(vnode);
vnode[PARENT] = undefined;

if (ummountHook) ummountHook(vnode);
if (ummountHook) ummountHook(vnode);

return str;
} catch (error) {
if (!asyncMode) throw error;

if (!error || typeof error.then !== 'function') throw error;

const renderNestedChildren = () => {
try {
return renderChildren();
} catch (e) {
return e.then(
() => renderChildren(),
() => renderNestedChildren()
);
}
};

return str;
return error.then(() => renderNestedChildren());
}
}

// Serialize Element VNodes to HTML
Expand Down Expand Up @@ -476,7 +605,14 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
// recurse into this element VNode's children
let childSvgMode =
type === 'svg' || (type !== 'foreignObject' && isSvgMode);
html = _renderToString(children, context, childSvgMode, selectValue, vnode);
html = _renderToString(
children,
context,
childSvgMode,
selectValue,
vnode,
asyncMode
);
}

if (afterDiff) afterDiff(vnode);
Expand All @@ -488,7 +624,13 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
return s + '/>';
}

return s + '>' + html + '</' + type + '>';
const endTag = '</' + type + '>';
const startTag = s + '>';

if (Array.isArray(html)) return [startTag, ...html, endTag];
else if (typeof html !== 'string') return [startTag, html, endTag];

return startTag + html + endTag;
}

const SELF_CLOSING = new Set([
Expand Down
14 changes: 14 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,17 @@ export function createComponent(vnode, context) {
__h: []
};
}

/**
* @template T
*/
export class Deferred {
constructor() {
// eslint-disable-next-line lines-around-comment
/** @type {Promise<T>} */
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
Loading
Loading