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

Merged
merged 3 commits into from
Jan 15, 2025
Merged
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
59 changes: 44 additions & 15 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const EMPTY_ARR = [];
const isArray = Array.isArray;
const assign = Object.assign;
const EMPTY_STR = '';
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 @@ -372,7 +374,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 @@ -403,6 +412,7 @@ function _renderToString(
selectValue,
vnode,
asyncMode,
false,
renderer
);
} catch (err) {
Expand Down Expand Up @@ -475,6 +485,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 @@ -503,7 +528,7 @@ function _renderToString(

const renderNestedChildren = () => {
try {
return _renderToString(
const result = _renderToString(
rendered,
context,
isSvgMode,
Expand All @@ -512,22 +537,26 @@ 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);
}
};

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,7 +340,10 @@ 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-->`
);
});

describe('dangerouslySetInnerHTML', () => {
Expand Down
Loading