Skip to content

Commit 6acc97a

Browse files
JoviDeCroocklemonmadeDavid Dios
authored
Simple suspense renderer 2024 (#333)
* Simple Suspense renderer * update simple suspense rendere * add a possible promise string as the return value * Update test/compat/async.test.js * Create pink-gifts-kneel.md * non breaking * Update async.test.js * fixing nested Suspense boundaries (#334) * fixing multiple suspended child components (#335) --------- Co-authored-by: Chris Sauve <[email protected]> Co-authored-by: David Dios <[email protected]>
1 parent 22cb846 commit 6acc97a

File tree

8 files changed

+363
-29
lines changed

8 files changed

+363
-29
lines changed

.changeset/pink-gifts-kneel.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"preact-render-to-string": minor
3+
---
4+
5+
Allow prepass like behavior where a Promise
6+
will be awaited and then continued, this is done with
7+
the new `renderToStringAsync` export

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"copy-typescript-definition": "copyfiles -f src/*.d.ts dist",
3636
"test": "eslint src test && tsc && npm run test:mocha && npm run test:mocha:compat && npm run test:mocha:debug && npm run bench",
3737
"test:mocha": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js test/*.test.js",
38-
"test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/compat/index.test.js'",
38+
"test:mocha:compat": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/compat/*.test.js'",
3939
"test:mocha:debug": "BABEL_ENV=test mocha -r @babel/register -r test/setup.js 'test/debug/index.test.js'",
4040
"format": "prettier src/**/*.{d.ts,js} test/**/*.js --write",
4141
"prepublishOnly": "npm run build",

src/index.d.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { VNode } from 'preact';
22

3-
export default function renderToString<P = {}>(vnode: VNode<P>, context?: any): string;
3+
export default function renderToString<P = {}>(
4+
vnode: VNode<P>,
5+
context?: any
6+
): string;
47

58
export function render<P = {}>(vnode: VNode<P>, context?: any): string;
69
export function renderToString<P = {}>(vnode: VNode<P>, context?: any): string;
7-
export function renderToStaticMarkup<P = {}>(vnode: VNode<P>, context?: any): string;
10+
export function renderToStringAsync<P = {}>(
11+
vnode: VNode<P>,
12+
context?: any
13+
): string | Promise<string>;
14+
export function renderToStaticMarkup<P = {}>(
15+
vnode: VNode<P>,
16+
context?: any
17+
): string;

src/index.js

Lines changed: 166 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,74 @@ export function renderToString(vnode, context) {
6060
context || EMPTY_OBJ,
6161
false,
6262
undefined,
63-
parent
63+
parent,
64+
false
6465
);
66+
} catch (e) {
67+
if (e.then) {
68+
throw new Error('Use "renderToStringAsync" for suspenseful rendering.');
69+
}
70+
71+
throw e;
72+
} finally {
73+
// options._commit, we don't schedule any effects in this library right now,
74+
// so we can pass an empty queue to this hook.
75+
if (options[COMMIT]) options[COMMIT](vnode, EMPTY_ARR);
76+
options[SKIP_EFFECTS] = previousSkipEffects;
77+
EMPTY_ARR.length = 0;
78+
}
79+
}
80+
81+
/**
82+
* Render Preact JSX + Components to an HTML string.
83+
* @param {VNode} vnode JSX Element / VNode to render
84+
* @param {Object} [context={}] Initial root context object
85+
* @returns {string} serialized HTML
86+
*/
87+
export async function renderToStringAsync(vnode, context) {
88+
// Performance optimization: `renderToString` is synchronous and we
89+
// therefore don't execute any effects. To do that we pass an empty
90+
// array to `options._commit` (`__c`). But we can go one step further
91+
// and avoid a lot of dirty checks and allocations by setting
92+
// `options._skipEffects` (`__s`) too.
93+
const previousSkipEffects = options[SKIP_EFFECTS];
94+
options[SKIP_EFFECTS] = true;
95+
96+
// store options hooks once before each synchronous render call
97+
beforeDiff = options[DIFF];
98+
afterDiff = options[DIFFED];
99+
renderHook = options[RENDER];
100+
ummountHook = options.unmount;
101+
102+
const parent = h(Fragment, null);
103+
parent[CHILDREN] = [vnode];
104+
105+
try {
106+
const rendered = _renderToString(
107+
vnode,
108+
context || EMPTY_OBJ,
109+
false,
110+
undefined,
111+
parent,
112+
true
113+
);
114+
115+
if (Array.isArray(rendered)) {
116+
let count = 0;
117+
let resolved = rendered;
118+
119+
// Resolving nested Promises with a maximum depth of 25
120+
while (
121+
resolved.some((element) => typeof element.then === 'function') &&
122+
count++ < 25
123+
) {
124+
resolved = (await Promise.all(resolved)).flat();
125+
}
126+
127+
return resolved.join('');
128+
}
129+
130+
return rendered;
65131
} finally {
66132
// options._commit, we don't schedule any effects in this library right now,
67133
// so we can pass an empty queue to this hook.
@@ -137,9 +203,17 @@ function renderClassComponent(vnode, context) {
137203
* @param {boolean} isSvgMode
138204
* @param {any} selectValue
139205
* @param {VNode} parent
140-
* @returns {string}
206+
* @param {boolean} asyncMode
207+
* @returns {string | Promise<string> | (string | Promise<string>)[]}
141208
*/
142-
function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
209+
function _renderToString(
210+
vnode,
211+
context,
212+
isSvgMode,
213+
selectValue,
214+
parent,
215+
asyncMode
216+
) {
143217
// Ignore non-rendered VNodes/values
144218
if (vnode == null || vnode === true || vnode === false || vnode === '') {
145219
return '';
@@ -153,16 +227,44 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
153227

154228
// Recurse into children / Arrays
155229
if (isArray(vnode)) {
156-
let rendered = '';
230+
let rendered = '',
231+
renderArray;
157232
parent[CHILDREN] = vnode;
158233
for (let i = 0; i < vnode.length; i++) {
159234
let child = vnode[i];
160235
if (child == null || typeof child === 'boolean') continue;
161236

162-
rendered =
163-
rendered +
164-
_renderToString(child, context, isSvgMode, selectValue, parent);
237+
const childRender = _renderToString(
238+
child,
239+
context,
240+
isSvgMode,
241+
selectValue,
242+
parent,
243+
asyncMode
244+
);
245+
246+
if (typeof childRender === 'string') {
247+
rendered += childRender;
248+
} else {
249+
renderArray = renderArray || [];
250+
251+
if (rendered) renderArray.push(rendered);
252+
253+
rendered = '';
254+
255+
if (Array.isArray(childRender)) {
256+
renderArray.push(...childRender);
257+
} else {
258+
renderArray.push(childRender);
259+
}
260+
}
261+
}
262+
263+
if (renderArray) {
264+
if (rendered) renderArray.push(rendered);
265+
return renderArray;
165266
}
267+
166268
return rendered;
167269
}
168270

@@ -202,7 +304,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
202304
context,
203305
isSvgMode,
204306
selectValue,
205-
vnode
307+
vnode,
308+
asyncMode
206309
);
207310
} else {
208311
// Values are pre-escaped by the JSX transform
@@ -282,7 +385,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
282385
context,
283386
isSvgMode,
284387
selectValue,
285-
vnode
388+
vnode,
389+
asyncMode
286390
);
287391
return str;
288392
} catch (err) {
@@ -313,7 +417,8 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
313417
context,
314418
isSvgMode,
315419
selectValue,
316-
vnode
420+
vnode,
421+
asyncMode
317422
);
318423
}
319424

@@ -333,20 +438,44 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
333438
rendered != null && rendered.type === Fragment && rendered.key == null;
334439
rendered = isTopLevelFragment ? rendered.props.children : rendered;
335440

336-
// Recurse into children before invoking the after-diff hook
337-
const str = _renderToString(
338-
rendered,
339-
context,
340-
isSvgMode,
341-
selectValue,
342-
vnode
343-
);
344-
if (afterDiff) afterDiff(vnode);
345-
vnode[PARENT] = undefined;
441+
const renderChildren = () =>
442+
_renderToString(
443+
rendered,
444+
context,
445+
isSvgMode,
446+
selectValue,
447+
vnode,
448+
asyncMode
449+
);
450+
451+
try {
452+
// Recurse into children before invoking the after-diff hook
453+
const str = renderChildren();
454+
455+
if (afterDiff) afterDiff(vnode);
456+
vnode[PARENT] = undefined;
346457

347-
if (ummountHook) ummountHook(vnode);
458+
if (ummountHook) ummountHook(vnode);
459+
460+
return str;
461+
} catch (error) {
462+
if (!asyncMode) throw error;
463+
464+
if (!error || typeof error.then !== 'function') throw error;
465+
466+
const renderNestedChildren = () => {
467+
try {
468+
return renderChildren();
469+
} catch (e) {
470+
return e.then(
471+
() => renderChildren(),
472+
() => renderNestedChildren()
473+
);
474+
}
475+
};
348476

349-
return str;
477+
return error.then(() => renderNestedChildren());
478+
}
350479
}
351480

352481
// Serialize Element VNodes to HTML
@@ -476,7 +605,14 @@ function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
476605
// recurse into this element VNode's children
477606
let childSvgMode =
478607
type === 'svg' || (type !== 'foreignObject' && isSvgMode);
479-
html = _renderToString(children, context, childSvgMode, selectValue, vnode);
608+
html = _renderToString(
609+
children,
610+
context,
611+
childSvgMode,
612+
selectValue,
613+
vnode,
614+
asyncMode
615+
);
480616
}
481617

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

491-
return s + '>' + html + '</' + type + '>';
627+
const endTag = '</' + type + '>';
628+
const startTag = s + '>';
629+
630+
if (Array.isArray(html)) return [startTag, ...html, endTag];
631+
else if (typeof html !== 'string') return [startTag, html, endTag];
632+
633+
return startTag + html + endTag;
492634
}
493635

494636
const SELF_CLOSING = new Set([

src/util.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,17 @@ export function createComponent(vnode, context) {
150150
__h: []
151151
};
152152
}
153+
154+
/**
155+
* @template T
156+
*/
157+
export class Deferred {
158+
constructor() {
159+
// eslint-disable-next-line lines-around-comment
160+
/** @type {Promise<T>} */
161+
this.promise = new Promise((resolve, reject) => {
162+
this.resolve = resolve;
163+
this.reject = reject;
164+
});
165+
}
166+
}

0 commit comments

Comments
 (0)