Skip to content

Commit

Permalink
Denote suspenseful components with comment markers (#376)
Browse files Browse the repository at this point in the history
* Denote suspenseful components with comment markers

* Add changeset

* use shorter notation
  • Loading branch information
JoviDeCroock authored Jan 15, 2025
1 parent b2050f4 commit 81e7da3
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 21 deletions.
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

0 comments on commit 81e7da3

Please sign in to comment.