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

Denote suspenseful components with comment markers #376

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/happy-peas-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'preact-render-to-string': minor
---

Insert comment markers for suspended trees, only in renderToStringAsync
68 changes: 48 additions & 20 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ import {
} from './lib/constants.js';

const EMPTY_ARR = [];
const EMPTY_OBJ = {};
const isArray = Array.isArray;
const assign = Object.assign;
const BEGIN_SUSPENSE_DENOMINATOR = '<!--$s-->';
const END_SUSPENSE_DENOMINATOR = '<!--/$s-->';

// Global state for the current render pass
let beforeDiff, afterDiff, renderHook, ummountHook;
Expand Down Expand Up @@ -65,7 +68,7 @@ export function renderToString(vnode, context, _rendererState) {
_rendererState
);

if (Array.isArray(rendered)) {
if (isArray(rendered)) {
return rendered.join('');
}
return rendered;
Expand Down Expand Up @@ -119,7 +122,7 @@ export async function renderToStringAsync(vnode, context) {
undefined
);

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

Expand Down Expand Up @@ -149,8 +152,6 @@ function markAsDirty() {
this.__d = true;
}

const EMPTY_OBJ = {};

/**
* @param {VNode} vnode
* @param {Record<string, unknown>} context
Expand Down Expand Up @@ -368,7 +369,14 @@ function _renderToString(

if (renderHook) renderHook(vnode);

rendered = type.call(component, props, cctx);
try {
rendered = type.call(component, props, cctx);
} catch (e) {
if (asyncMode) {
vnode._suspended = true;
}
throw e;
}
}
component[DIRTY] = true;
}
Expand Down Expand Up @@ -398,6 +406,7 @@ function _renderToString(
selectValue,
vnode,
asyncMode,
false,
renderer
);
return str;
Expand Down Expand Up @@ -472,6 +481,21 @@ function _renderToString(

if (options.unmount) options.unmount(vnode);

if (vnode._suspended) {
if (typeof str === 'string') {
return BEGIN_SUSPENSE_DENOMINATOR + str + END_SUSPENSE_DENOMINATOR;
} else if (isArray(str)) {
str.unshift(BEGIN_SUSPENSE_DENOMINATOR);
str.push(END_SUSPENSE_DENOMINATOR);
return str;
}

return str.then(
(resolved) =>
BEGIN_SUSPENSE_DENOMINATOR + resolved + END_SUSPENSE_DENOMINATOR
);
}

return str;
} catch (error) {
if (!asyncMode && renderer && renderer.onError) {
Expand Down Expand Up @@ -500,7 +524,7 @@ function _renderToString(

const renderNestedChildren = () => {
try {
return _renderToString(
const result = _renderToString(
rendered,
context,
isSvgMode,
Expand All @@ -509,26 +533,30 @@ function _renderToString(
asyncMode,
renderer
);
return vnode._suspended
? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
: result;
} catch (e) {
if (!e || typeof e.then !== 'function') throw e;

return e.then(
() =>
_renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode,
renderer
),
() => renderNestedChildren()
);
return e.then(() => {
const result = _renderToString(
rendered,
context,
isSvgMode,
selectValue,
vnode,
asyncMode,
renderer
);
return vnode._suspended
? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
: result;
}, renderNestedChildren);
}
};

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

Expand Down
131 changes: 125 additions & 6 deletions test/compat/async.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { h, Fragment } from 'preact';
import { Suspense, useId, lazy, createContext } from 'preact/compat';
import { expect } from 'chai';
import { createSuspender } from '../utils.jsx';
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

describe('Async renderToString', () => {
it('should render JSX after a suspense boundary', async () => {
Expand All @@ -16,7 +17,30 @@ describe('Async renderToString', () => {
</Suspense>
);

const expected = `<div class="foo">bar</div>`;
const expected = `<!--$s--><div class="foo">bar</div><!--/$s-->`;

suspended.resolve();

const rendered = await promise;

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

it('should correctly denote null returns of suspending components', async () => {
const { Suspender, suspended } = createSuspender();

const Analytics = () => null;

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

const expected = `<!--$s--><!--/$s--><div class="foo">bar</div>`;

suspended.resolve();

Expand Down Expand Up @@ -49,7 +73,7 @@ describe('Async renderToString', () => {
</ul>
);

const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><li>three</li><!--/$s--></ul>`;

suspendedOne.resolve();
suspendedTwo.resolve();
Expand Down Expand Up @@ -85,10 +109,102 @@ describe('Async renderToString', () => {
</ul>
);

const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><li>three</li><!--/$s--></ul>`;

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

const rendered = await promise;

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

it('should render JSX with nested suspense boundaries containing multiple suspending components', async () => {
const {
Suspender: SuspenderOne,
suspended: suspendedOne
} = createSuspender();
const {
Suspender: SuspenderTwo,
suspended: suspendedTwo
} = createSuspender();
const {
Suspender: SuspenderThree,
suspended: suspendedThree
} = createSuspender('three');

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

const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><!--$s--><li>three</li><!--/$s--><li>four</li><!--/$s--></ul>`;

suspendedOne.resolve();
suspendedTwo.resolve();
await wait(0);
suspendedThree.resolve();

const rendered = await promise;

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

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

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

const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--$s--><li>three</li><!--/$s--><!--/$s--><li>four</li><!--/$s--></ul>`;

suspendedOne.resolve();
suspendedTwo.resolve();
await wait(0);
suspendedThree.resolve();

const rendered = await promise;

Expand Down Expand Up @@ -127,7 +243,7 @@ describe('Async renderToString', () => {
</ul>
);

const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
const expected = `<ul><!--$s--><li>one</li><!--/$s--><!--$s--><li>two</li><!--/$s--><!--$s--><li>three</li><!--/$s--></ul>`;

suspendedOne.resolve();
suspendedTwo.resolve();
Expand Down Expand Up @@ -187,7 +303,7 @@ describe('Async renderToString', () => {

suspended.resolve();
const rendered = await promise;
expect(rendered).to.equal('<p>ok</p>');
expect(rendered).to.equal('<!--$s--><p>ok</p><!--/$s-->');
});

it('should work with an in-render suspension', async () => {
Expand Down Expand Up @@ -224,6 +340,9 @@ describe('Async renderToString', () => {
</Context.Provider>
);

expect(rendered).to.equal(`<div>2</div>`);
// Before we get to the actual DOM this suspends twice
expect(rendered).to.equal(
`<!--$s--><!--$s--><div>2</div><!--/$s--><!--/$s-->`
);
});
});