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
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
20 changes: 16 additions & 4 deletions src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
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 | Promise<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 render<P = {}>(
vnode: VNode<P>,
context?: any
): string | Promise<string>;
export function renderToString<P = {}>(
vnode: VNode<P>,
context?: any
): string | Promise<string>;
export function renderToStaticMarkup<P = {}>(
vnode: VNode<P>,
context?: any
): string | Promise<string>;
89 changes: 68 additions & 21 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,19 @@ export function renderToString(vnode, context) {
parent[CHILDREN] = [vnode];

try {
return _renderToString(
const rendered = _renderToString(
vnode,
context || EMPTY_OBJ,
false,
undefined,
parent
);

if (Array.isArray(rendered)) {
return Promise.all(rendered).then((rendered) => rendered.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,7 +143,7 @@ function renderClassComponent(vnode, context) {
* @param {boolean} isSvgMode
* @param {any} selectValue
* @param {VNode} parent
* @returns {string}
* @returns {string | Promise<string> | (string | Promise<string>)[]}
*/
function _renderToString(vnode, context, isSvgMode, selectValue, parent) {
// Ignore non-rendered VNodes/values
Expand All @@ -153,16 +159,43 @@ 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
);

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 @@ -333,20 +366,28 @@ 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;

if (ummountHook) ummountHook(vnode);

return str;
try {
// Recurse into children before invoking the after-diff hook
const str = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode
);
if (afterDiff) afterDiff(vnode);
vnode[PARENT] = undefined;

if (ummountHook) ummountHook(vnode);

return str;
} catch (error) {
if (!error || typeof error.then !== 'function') throw error;

return error.then(() =>
_renderToString(rendered, context, isSvgMode, selectValue, vnode)
);
}
}

// Serialize Element VNodes to HTML
Expand Down Expand Up @@ -488,7 +529,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;
});
}
}
68 changes: 68 additions & 0 deletions test/compat/async.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import render from '../../src/index.js';
import {
h,
Component,

Check failure on line 4 in test/compat/async.test.js

View workflow job for this annotation

GitHub Actions / Build & Test

'Component' is defined but never used. Allowed unused vars must match /^h$/u
createContext,

Check failure on line 5 in test/compat/async.test.js

View workflow job for this annotation

GitHub Actions / Build & Test

'createContext' is defined but never used. Allowed unused vars must match /^h$/u
Fragment,

Check failure on line 6 in test/compat/async.test.js

View workflow job for this annotation

GitHub Actions / Build & Test

'Fragment' is defined but never used. Allowed unused vars must match /^h$/u
options,

Check failure on line 7 in test/compat/async.test.js

View workflow job for this annotation

GitHub Actions / Build & Test

'options' is defined but never used. Allowed unused vars must match /^h$/u
createRef

Check failure on line 8 in test/compat/async.test.js

View workflow job for this annotation

GitHub Actions / Build & Test

'createRef' is defined but never used. Allowed unused vars must match /^h$/u
} from 'preact';
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
import { Suspense } from 'preact/compat';
import { expect } from 'chai';
import { createSuspender } from '../utils.js';

describe('Async renderToString', () => {
it('should render JSX after a suspense boundary', async () => {
const { Suspender, suspended } = createSuspender();

const promise = render(
<Suspense fallback={<div>loading...</div>}>
<Suspender>
<div class="foo">bar</div>
</Suspender>
</Suspense>
);

const expected = `<div class="foo">bar</div>`;

suspended.resolve();

const rendered = await promise;

expect(rendered).to.equal(expected);
});

it('should render JSX with nested suspense boundary', async () => {
const {
Suspender: SuspenderOne,
suspended: suspendedOne
} = createSuspender();
const {
Suspender: SuspenderTwo,
suspended: suspendedTwo
} = createSuspender();

const promise = render(
<ul>
<Suspense fallback={null}>
<SuspenderOne>
<li>one</li>
<SuspenderTwo>
<li>two</li>
</SuspenderTwo>
<li>three</li>
</SuspenderOne>
</Suspense>
</ul>
);

const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;

suspendedOne.resolve();
suspendedTwo.resolve();

const rendered = await promise;

expect(rendered).to.equal(expected);
});
});
21 changes: 21 additions & 0 deletions test/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Deferred } from '../src/util';

/**
* tag to remove leading whitespace from tagged template
* literal.
Expand All @@ -11,6 +13,25 @@ export function dedent([str]) {
.replace(/(^\n+|\n+\s*$)/g, '');
}

export function createSuspender() {
const deferred = new Deferred();
let resolved;

deferred.promise.then(() => (resolved = true));
function Suspender({ children = null }) {
if (!resolved) {
throw deferred.promise;
}

return children;
}

return {
suspended: deferred,
Suspender
};
}

export const svgAttributes = {
accentHeight: 'accent-height',
accumulate: 'accumulate',
Expand Down
Loading