Skip to content

Commit 81e7da3

Browse files
authored
Denote suspenseful components with comment markers (#376)
* Denote suspenseful components with comment markers * Add changeset * use shorter notation
1 parent b2050f4 commit 81e7da3

File tree

3 files changed

+174
-21
lines changed

3 files changed

+174
-21
lines changed

.changeset/happy-peas-type.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'preact-render-to-string': minor
3+
---
4+
5+
Insert comment markers for suspended trees, only in renderToStringAsync

src/index.js

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const EMPTY_ARR = [];
2929
const isArray = Array.isArray;
3030
const assign = Object.assign;
3131
const EMPTY_STR = '';
32+
const BEGIN_SUSPENSE_DENOMINATOR = '<!--$s-->';
33+
const END_SUSPENSE_DENOMINATOR = '<!--/$s-->';
3234

3335
// Global state for the current render pass
3436
let beforeDiff, afterDiff, renderHook, ummountHook;
@@ -372,7 +374,14 @@ function _renderToString(
372374

373375
if (renderHook) renderHook(vnode);
374376

375-
rendered = type.call(component, props, cctx);
377+
try {
378+
rendered = type.call(component, props, cctx);
379+
} catch (e) {
380+
if (asyncMode) {
381+
vnode._suspended = true;
382+
}
383+
throw e;
384+
}
376385
}
377386
component[DIRTY] = true;
378387
}
@@ -403,6 +412,7 @@ function _renderToString(
403412
selectValue,
404413
vnode,
405414
asyncMode,
415+
false,
406416
renderer
407417
);
408418
} catch (err) {
@@ -475,6 +485,21 @@ function _renderToString(
475485

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

488+
if (vnode._suspended) {
489+
if (typeof str === 'string') {
490+
return BEGIN_SUSPENSE_DENOMINATOR + str + END_SUSPENSE_DENOMINATOR;
491+
} else if (isArray(str)) {
492+
str.unshift(BEGIN_SUSPENSE_DENOMINATOR);
493+
str.push(END_SUSPENSE_DENOMINATOR);
494+
return str;
495+
}
496+
497+
return str.then(
498+
(resolved) =>
499+
BEGIN_SUSPENSE_DENOMINATOR + resolved + END_SUSPENSE_DENOMINATOR
500+
);
501+
}
502+
478503
return str;
479504
} catch (error) {
480505
if (!asyncMode && renderer && renderer.onError) {
@@ -503,7 +528,7 @@ function _renderToString(
503528

504529
const renderNestedChildren = () => {
505530
try {
506-
return _renderToString(
531+
const result = _renderToString(
507532
rendered,
508533
context,
509534
isSvgMode,
@@ -512,22 +537,26 @@ function _renderToString(
512537
asyncMode,
513538
renderer
514539
);
540+
return vnode._suspended
541+
? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
542+
: result;
515543
} catch (e) {
516544
if (!e || typeof e.then != 'function') throw e;
517545

518-
return e.then(
519-
() =>
520-
_renderToString(
521-
rendered,
522-
context,
523-
isSvgMode,
524-
selectValue,
525-
vnode,
526-
asyncMode,
527-
renderer
528-
),
529-
renderNestedChildren
530-
);
546+
return e.then(() => {
547+
const result = _renderToString(
548+
rendered,
549+
context,
550+
isSvgMode,
551+
selectValue,
552+
vnode,
553+
asyncMode,
554+
renderer
555+
);
556+
return vnode._suspended
557+
? BEGIN_SUSPENSE_DENOMINATOR + result + END_SUSPENSE_DENOMINATOR
558+
: result;
559+
}, renderNestedChildren);
531560
}
532561
};
533562

test/compat/async.test.jsx

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { h, Fragment } from 'preact';
33
import { Suspense, useId, lazy, createContext } from 'preact/compat';
44
import { expect } from 'chai';
55
import { createSuspender } from '../utils.jsx';
6+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
67

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

19-
const expected = `<div class="foo">bar</div>`;
20+
const expected = `<!--$s--><div class="foo">bar</div><!--/$s-->`;
21+
22+
suspended.resolve();
23+
24+
const rendered = await promise;
25+
26+
expect(rendered).to.equal(expected);
27+
});
28+
29+
it('should correctly denote null returns of suspending components', async () => {
30+
const { Suspender, suspended } = createSuspender();
31+
32+
const Analytics = () => null;
33+
34+
const promise = renderToStringAsync(
35+
<Suspense fallback={<div>loading...</div>}>
36+
<Suspender>
37+
<Analytics />
38+
</Suspender>
39+
<div class="foo">bar</div>
40+
</Suspense>
41+
);
42+
43+
const expected = `<!--$s--><!--/$s--><div class="foo">bar</div>`;
2044

2145
suspended.resolve();
2246

@@ -49,7 +73,7 @@ describe('Async renderToString', () => {
4973
</ul>
5074
);
5175

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

5478
suspendedOne.resolve();
5579
suspendedTwo.resolve();
@@ -85,10 +109,102 @@ describe('Async renderToString', () => {
85109
</ul>
86110
);
87111

88-
const expected = `<ul><li>one</li><li>two</li><li>three</li></ul>`;
112+
const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><li>three</li><!--/$s--></ul>`;
113+
114+
suspendedOne.resolve();
115+
suspendedTwo.resolve();
116+
117+
const rendered = await promise;
118+
119+
expect(rendered).to.equal(expected);
120+
});
121+
122+
it('should render JSX with nested suspense boundaries containing multiple suspending components', async () => {
123+
const {
124+
Suspender: SuspenderOne,
125+
suspended: suspendedOne
126+
} = createSuspender();
127+
const {
128+
Suspender: SuspenderTwo,
129+
suspended: suspendedTwo
130+
} = createSuspender();
131+
const {
132+
Suspender: SuspenderThree,
133+
suspended: suspendedThree
134+
} = createSuspender('three');
135+
136+
const promise = renderToStringAsync(
137+
<ul>
138+
<Suspense fallback={null}>
139+
<SuspenderOne>
140+
<li>one</li>
141+
<Suspense fallback={null}>
142+
<SuspenderTwo>
143+
<li>two</li>
144+
</SuspenderTwo>
145+
<SuspenderThree>
146+
<li>three</li>
147+
</SuspenderThree>
148+
</Suspense>
149+
<li>four</li>
150+
</SuspenderOne>
151+
</Suspense>
152+
</ul>
153+
);
154+
155+
const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--/$s--><!--$s--><li>three</li><!--/$s--><li>four</li><!--/$s--></ul>`;
89156

90157
suspendedOne.resolve();
91158
suspendedTwo.resolve();
159+
await wait(0);
160+
suspendedThree.resolve();
161+
162+
const rendered = await promise;
163+
164+
expect(rendered).to.equal(expected);
165+
});
166+
167+
it('should render JSX with deeply nested suspense boundaries', async () => {
168+
const {
169+
Suspender: SuspenderOne,
170+
suspended: suspendedOne
171+
} = createSuspender();
172+
const {
173+
Suspender: SuspenderTwo,
174+
suspended: suspendedTwo
175+
} = createSuspender();
176+
const {
177+
Suspender: SuspenderThree,
178+
suspended: suspendedThree
179+
} = createSuspender();
180+
181+
const promise = renderToStringAsync(
182+
<ul>
183+
<Suspense fallback={null}>
184+
<SuspenderOne>
185+
<li>one</li>
186+
<Suspense fallback={null}>
187+
<SuspenderTwo>
188+
<li>two</li>
189+
<Suspense fallback={null}>
190+
<SuspenderThree>
191+
<li>three</li>
192+
</SuspenderThree>
193+
</Suspense>
194+
</SuspenderTwo>
195+
</Suspense>
196+
<li>four</li>
197+
</SuspenderOne>
198+
</Suspense>
199+
</ul>
200+
);
201+
202+
const expected = `<ul><!--$s--><li>one</li><!--$s--><li>two</li><!--$s--><li>three</li><!--/$s--><!--/$s--><li>four</li><!--/$s--></ul>`;
203+
204+
suspendedOne.resolve();
205+
suspendedTwo.resolve();
206+
await wait(0);
207+
suspendedThree.resolve();
92208

93209
const rendered = await promise;
94210

@@ -127,7 +243,7 @@ describe('Async renderToString', () => {
127243
</ul>
128244
);
129245

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

132248
suspendedOne.resolve();
133249
suspendedTwo.resolve();
@@ -187,7 +303,7 @@ describe('Async renderToString', () => {
187303

188304
suspended.resolve();
189305
const rendered = await promise;
190-
expect(rendered).to.equal('<p>ok</p>');
306+
expect(rendered).to.equal('<!--$s--><p>ok</p><!--/$s-->');
191307
});
192308

193309
it('should work with an in-render suspension', async () => {
@@ -224,7 +340,10 @@ describe('Async renderToString', () => {
224340
</Context.Provider>
225341
);
226342

227-
expect(rendered).to.equal(`<div>2</div>`);
343+
// Before we get to the actual DOM this suspends twice
344+
expect(rendered).to.equal(
345+
`<!--$s--><!--$s--><div>2</div><!--/$s--><!--/$s-->`
346+
);
228347
});
229348

230349
describe('dangerouslySetInnerHTML', () => {

0 commit comments

Comments
 (0)