diff --git a/.changeset/pink-gifts-kneel.md b/.changeset/pink-gifts-kneel.md new file mode 100644 index 00000000..7bc5c1f4 --- /dev/null +++ b/.changeset/pink-gifts-kneel.md @@ -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 diff --git a/package-lock.json b/package-lock.json index ba54fea6..4d26bb77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "preact-render-to-string", - "version": "6.2.2", + "version": "6.3.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "preact-render-to-string", - "version": "6.2.2", + "version": "6.3.1", "license": "MIT", "dependencies": { "pretty-format": "^3.8.0" diff --git a/package.json b/package.json index b78968f1..79dab839 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.d.ts b/src/index.d.ts index 6b7b7e5d..81db2bd7 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,7 +1,17 @@ import { VNode } from 'preact'; -export default function renderToString
(vnode: VNode
, context?: any): string; +export default function renderToString
( + vnode: VNode
, + context?: any +): string; export function render
(vnode: VNode
, context?: any): string; export function renderToString
(vnode: VNode
, context?: any): string; -export function renderToStaticMarkup
(vnode: VNode
, context?: any): string; +export function renderToStringAsync
( + vnode: VNode
,
+ context?: any
+): string | Promise (
+ vnode: VNode ,
+ context?: any
+): string;
diff --git a/src/index.js b/src/index.js
index 84b00bbc..c52a067f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -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.');
+ }
+
+ 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.
@@ -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
+
+ );
+
+ const expected = `
`;
+
+ suspendedOne.resolve();
+ suspendedTwo.resolve();
+
+ const rendered = await promise;
+
+ expect(rendered).to.equal(expected);
+ });
+
+ it('should render JSX with nested suspense boundaries', async () => {
+ const {
+ Suspender: SuspenderOne,
+ suspended: suspendedOne
+ } = createSuspender();
+ const {
+ Suspender: SuspenderTwo,
+ suspended: suspendedTwo
+ } = createSuspender();
+
+ const promise = renderToStringAsync(
+
+
+ );
+
+ const expected = `
`;
+
+ suspendedOne.resolve();
+ suspendedTwo.resolve();
+
+ const rendered = await promise;
+
+ expect(rendered).to.equal(expected);
+ });
+
+ it('should render JSX with multiple suspended direct children within a single suspense boundary', async () => {
+ const {
+ Suspender: SuspenderOne,
+ suspended: suspendedOne
+ } = createSuspender();
+ const {
+ Suspender: SuspenderTwo,
+ suspended: suspendedTwo
+ } = createSuspender();
+ const {
+ Suspender: SuspenderThree,
+ suspended: suspendedThree
+ } = createSuspender();
+
+ const promise = renderToStringAsync(
+
+
+ );
+
+ const expected = `
`;
+
+ suspendedOne.resolve();
+ suspendedTwo.resolve();
+ suspendedThree.resolve();
+
+ const rendered = await promise;
+
+ expect(rendered).to.equal(expected);
+ });
+});
diff --git a/test/utils.js b/test/utils.js
index 4c2b5d2d..ae19dad8 100644
--- a/test/utils.js
+++ b/test/utils.js
@@ -1,3 +1,5 @@
+import { Deferred } from '../src/util';
+
/**
* tag to remove leading whitespace from tagged template
* literal.
@@ -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',