inertjs-vector 1.0.0-beta.7 → 1.0.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +13 -4
- package/src/stream.js +147 -44
- package/test/streaming-error.test.js +94 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { analyzeTemplateStrings, escape, CONTEXT } from './escaper.js';
|
|
2
2
|
import { minifyHTML } from 'inertjs-optimizer';
|
|
3
3
|
export { resolveToString } from './resolve.js';
|
|
4
|
+
export { renderToStream, DeferTimeoutError } from './stream.js';
|
|
5
|
+
// NOTE: index <-> stream is a cycle (stream imports RawString from here). Both
|
|
6
|
+
// only touch each other's bindings at call time, by which point ESM has finished
|
|
7
|
+
// initialising both modules, so the re-export above is safe.
|
|
4
8
|
|
|
5
9
|
const planCache = new WeakMap();
|
|
6
10
|
|
|
@@ -44,13 +48,17 @@ export function raw(str, suppressWarning = false) {
|
|
|
44
48
|
* `fallback` / `error` content is treated as trusted HTML (like `raw()`), so
|
|
45
49
|
* interpolate untrusted values through `vec\`\`` before handing them in.
|
|
46
50
|
*
|
|
47
|
-
* @param {Promise<any>|AsyncIterable<any>|object|(() => any)} source
|
|
48
|
-
* The deferred value, or a factory that produces it (called lazily
|
|
49
|
-
*
|
|
51
|
+
* @param {Promise<any>|AsyncIterable<any>|object|((ctx: { signal: AbortSignal }) => any)} source
|
|
52
|
+
* The deferred value, or a factory that produces it (called lazily with an
|
|
53
|
+
* `{ signal }` that aborts when the response is torn down; a throw from the
|
|
54
|
+
* factory is caught and routed to `error`).
|
|
50
55
|
* @param {object} [options]
|
|
51
56
|
* @param {string|RawString} [options.fallback] Skeleton shown while pending.
|
|
52
57
|
* @param {string|RawString|object|((err: Error) => string|RawString|object)} [options.error]
|
|
53
|
-
* Content to patch in if `source` rejects. A function receives the error.
|
|
58
|
+
* Content to patch in if `source` rejects or times out. A function receives the error.
|
|
59
|
+
* @param {number} [options.timeout]
|
|
60
|
+
* Milliseconds to wait before failing the fragment with a `DeferTimeoutError`
|
|
61
|
+
* (routed to `error` / `onError` like any other rejection).
|
|
54
62
|
* @returns {DeferredFragment}
|
|
55
63
|
*/
|
|
56
64
|
export function defer(source, options = {}) {
|
|
@@ -64,6 +72,7 @@ export class DeferredFragment {
|
|
|
64
72
|
this.source = source;
|
|
65
73
|
this.fallback = options.fallback ?? null;
|
|
66
74
|
this.error = options.error ?? null;
|
|
75
|
+
this.timeout = options.timeout ?? null;
|
|
67
76
|
}
|
|
68
77
|
}
|
|
69
78
|
|
package/src/stream.js
CHANGED
|
@@ -1,28 +1,84 @@
|
|
|
1
|
-
import { escape } from './escaper.js';
|
|
1
|
+
import { escape, CONTEXT } from './escaper.js';
|
|
2
2
|
import { RawString } from './index.js';
|
|
3
3
|
|
|
4
4
|
const encoder = new TextEncoder();
|
|
5
5
|
const decoder = new TextDecoder();
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Error handed to the error boundary / `onError` hook when a deferred fragment
|
|
9
|
+
* exceeds its `timeout`.
|
|
10
|
+
*/
|
|
11
|
+
export class DeferTimeoutError extends Error {
|
|
12
|
+
constructor(slotId, ms) {
|
|
13
|
+
super(`Deferred fragment in slot ${slotId} timed out after ${ms}ms`);
|
|
14
|
+
this.name = 'DeferTimeoutError';
|
|
15
|
+
this.code = 'E_INERT_FRAGMENT_TIMEOUT';
|
|
16
|
+
this.slot = slotId;
|
|
17
|
+
this.timeout = ms;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isDev() {
|
|
22
|
+
return typeof process !== 'undefined' && !!process.env && process.env.NODE_ENV !== 'production';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One inline script, emitted once into the shell. It applies every streamed
|
|
27
|
+
* `<template data-i-fill>` patch — synchronously for those already parsed, and
|
|
28
|
+
* via a MutationObserver for those that arrive later in the stream. Replaces the
|
|
29
|
+
* previous approach of re-emitting a full patcher script after every patch
|
|
30
|
+
* (which was O(n²) and multiplied inline-script surface under CSP).
|
|
31
|
+
*/
|
|
32
|
+
function bootstrapScript(nonce) {
|
|
33
|
+
return `<script nonce="${nonce}">
|
|
8
34
|
(function(){
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
35
|
+
var W=window,D=document;
|
|
36
|
+
function patch(t){
|
|
37
|
+
var id=t.getAttribute('data-i-fill');
|
|
38
|
+
var slot=D.getElementById('i-slot-'+id);
|
|
13
39
|
if(slot){
|
|
14
|
-
|
|
15
|
-
slot.replaceWith(
|
|
40
|
+
var errored=t.hasAttribute('data-i-error');
|
|
41
|
+
slot.replaceWith(t.content);
|
|
16
42
|
if(errored){
|
|
17
|
-
|
|
43
|
+
var s=(W.__inert=W.__inert||{});
|
|
18
44
|
s.fragmentErrors=(s.fragmentErrors||0)+1;
|
|
19
|
-
try{
|
|
45
|
+
try{W.dispatchEvent(new CustomEvent('inert:fragment:error',{detail:{slot:id}}));}catch(e){}
|
|
20
46
|
}
|
|
21
47
|
}
|
|
22
|
-
|
|
48
|
+
if(t.parentNode)t.remove();
|
|
49
|
+
}
|
|
50
|
+
function sweep(root){
|
|
51
|
+
var ts=(root||D).querySelectorAll('template[data-i-fill]');
|
|
52
|
+
for(var i=0;i<ts.length;i++)patch(ts[i]);
|
|
53
|
+
}
|
|
54
|
+
sweep();
|
|
55
|
+
if(W.MutationObserver){
|
|
56
|
+
var mo=new MutationObserver(function(muts){
|
|
57
|
+
for(var i=0;i<muts.length;i++){
|
|
58
|
+
var added=muts[i].addedNodes;
|
|
59
|
+
for(var j=0;j<added.length;j++){
|
|
60
|
+
var n=added[j];
|
|
61
|
+
if(n.nodeType!==1)continue;
|
|
62
|
+
if(n.tagName==='TEMPLATE'&&n.hasAttribute('data-i-fill'))patch(n);
|
|
63
|
+
else if(n.querySelector&&n.querySelector('template[data-i-fill]'))sweep(n);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
mo.observe(D.documentElement,{childList:true,subtree:true});
|
|
68
|
+
var done=function(){sweep();mo.disconnect();};
|
|
69
|
+
if(D.readyState==='complete')done();
|
|
70
|
+
else W.addEventListener('load',done);
|
|
71
|
+
}else{
|
|
72
|
+
W.addEventListener('load',function(){sweep();});
|
|
23
73
|
}
|
|
24
74
|
})();
|
|
25
75
|
</script>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A bare patch: the bootstrap script (already in the shell) applies it. */
|
|
79
|
+
function fillPatch(slotId, html, errored) {
|
|
80
|
+
return `\n<template data-i-fill="${slotId}"${errored ? ' data-i-error="1"' : ''}>${html}</template>`;
|
|
81
|
+
}
|
|
26
82
|
|
|
27
83
|
/**
|
|
28
84
|
* Fully drains a WHATWG ReadableStream of Uint8Array chunks into a string,
|
|
@@ -48,15 +104,15 @@ async function drainStream(readable) {
|
|
|
48
104
|
* Resolves an already-settled value (the result of `await`ing a hole) into an
|
|
49
105
|
* HTML string, recursing through nested streams, RawStrings and arrays.
|
|
50
106
|
*/
|
|
51
|
-
async function valueToHtml(value, ctx, attrName, nonce) {
|
|
107
|
+
async function valueToHtml(value, ctx, attrName, nonce, options) {
|
|
52
108
|
if (value == null || value === false) return '';
|
|
53
109
|
if (value instanceof RawString) return value.value;
|
|
54
110
|
if (value && value.type === 'VecStream') {
|
|
55
|
-
return drainStream(renderToStream(value, nonce));
|
|
111
|
+
return drainStream(renderToStream(value, nonce, options));
|
|
56
112
|
}
|
|
57
113
|
if (Array.isArray(value)) {
|
|
58
114
|
let out = '';
|
|
59
|
-
for (const item of value) out += await valueToHtml(item, ctx, attrName, nonce);
|
|
115
|
+
for (const item of value) out += await valueToHtml(item, ctx, attrName, nonce, options);
|
|
60
116
|
return out;
|
|
61
117
|
}
|
|
62
118
|
return escape(value, ctx, attrName);
|
|
@@ -72,27 +128,41 @@ function skeletonHtml(fragment) {
|
|
|
72
128
|
}
|
|
73
129
|
|
|
74
130
|
/** Resolves a fragment's error boundary content once the source has rejected. */
|
|
75
|
-
async function errorBoundaryHtml(fragment, err, ctx, attrName, nonce) {
|
|
131
|
+
async function errorBoundaryHtml(fragment, err, slotId, ctx, attrName, nonce, options) {
|
|
76
132
|
let content = fragment && fragment.error;
|
|
77
133
|
if (typeof content === 'function') {
|
|
78
134
|
try {
|
|
79
135
|
content = content(err);
|
|
80
136
|
} catch (boundaryErr) {
|
|
81
137
|
console.error('[InertJS] Fragment error boundary threw:', boundaryErr);
|
|
82
|
-
|
|
138
|
+
content = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (content == null) {
|
|
142
|
+
if (isDev()) {
|
|
143
|
+
const msg = escape(err && err.message ? err.message : String(err), CONTEXT.TEXT);
|
|
144
|
+
return `<div data-inert-fragment-error style="border:1px dashed #f43f5e;background:#fff1f2;color:#9f1239;` +
|
|
145
|
+
`padding:6px 10px;font:12px/1.5 ui-monospace,monospace;border-radius:4px">` +
|
|
146
|
+
`⚠ InertJS fragment ${slotId} failed: ${msg}</div>`;
|
|
83
147
|
}
|
|
148
|
+
return '';
|
|
84
149
|
}
|
|
85
|
-
if (content == null) return '';
|
|
86
150
|
if (content instanceof RawString) return content.value;
|
|
87
151
|
if (content && content.type === 'VecStream') {
|
|
88
|
-
return drainStream(renderToStream(content, nonce));
|
|
152
|
+
return drainStream(renderToStream(content, nonce, options));
|
|
89
153
|
}
|
|
90
154
|
return String(content);
|
|
91
155
|
}
|
|
92
156
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
/** Races a fragment's html production against its `timeout`, if any. */
|
|
158
|
+
function withTimeout(promise, ms, slotId) {
|
|
159
|
+
if (!ms || ms <= 0) return promise;
|
|
160
|
+
let timer;
|
|
161
|
+
const timeout = new Promise((_, reject) => {
|
|
162
|
+
timer = setTimeout(() => reject(new DeferTimeoutError(slotId, ms)), ms);
|
|
163
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
164
|
+
});
|
|
165
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
96
166
|
}
|
|
97
167
|
|
|
98
168
|
/**
|
|
@@ -110,7 +180,10 @@ function fillPatch(slotId, html, nonce, errored) {
|
|
|
110
180
|
* @param {string} [nonce] The CSP nonce for inline scripts
|
|
111
181
|
* @param {object} [options]
|
|
112
182
|
* @param {(err: Error, info: { slot: number }) => void} [options.onError]
|
|
113
|
-
* Called for every hole that rejects after the shell
|
|
183
|
+
* Called for every hole that rejects (or times out) after the shell flushed.
|
|
184
|
+
* @param {AbortSignal} [options.signal]
|
|
185
|
+
* Aborting it stops the stream and is forwarded to deferred fragment
|
|
186
|
+
* factories as `defer(({ signal }) => ...)`.
|
|
114
187
|
* @returns {ReadableStream}
|
|
115
188
|
*/
|
|
116
189
|
export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
@@ -126,6 +199,17 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
126
199
|
|
|
127
200
|
const { plan, values } = vecResult;
|
|
128
201
|
const onError = typeof options.onError === 'function' ? options.onError : null;
|
|
202
|
+
|
|
203
|
+
// Every render has a real AbortSignal so `defer` factories can always rely on
|
|
204
|
+
// `({ signal })`. It fires when the caller's signal fires, when the consumer
|
|
205
|
+
// cancels the stream, or when a write reveals the client has gone away.
|
|
206
|
+
const abort = new AbortController();
|
|
207
|
+
const signal = abort.signal;
|
|
208
|
+
if (options.signal) {
|
|
209
|
+
if (options.signal.aborted) abort.abort();
|
|
210
|
+
else options.signal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
211
|
+
}
|
|
212
|
+
|
|
129
213
|
let slotCounter = 0;
|
|
130
214
|
const pendingTasks = new Set();
|
|
131
215
|
|
|
@@ -134,12 +218,13 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
134
218
|
let closed = false;
|
|
135
219
|
|
|
136
220
|
const enqueue = (str) => {
|
|
137
|
-
if (closed) return;
|
|
221
|
+
if (closed || signal.aborted) return;
|
|
138
222
|
try {
|
|
139
223
|
controller.enqueue(encoder.encode(str));
|
|
140
224
|
} catch {
|
|
141
|
-
// Client went away; stop trying to write.
|
|
225
|
+
// Client went away; stop trying to write and let fragments bail.
|
|
142
226
|
closed = true;
|
|
227
|
+
abort.abort();
|
|
143
228
|
}
|
|
144
229
|
};
|
|
145
230
|
|
|
@@ -153,6 +238,12 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
153
238
|
}
|
|
154
239
|
};
|
|
155
240
|
|
|
241
|
+
if (signal.aborted) {
|
|
242
|
+
closeController();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
signal.addEventListener('abort', closeController, { once: true });
|
|
246
|
+
|
|
156
247
|
try {
|
|
157
248
|
let initialHtml = plan.statics[0];
|
|
158
249
|
|
|
@@ -173,28 +264,31 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
173
264
|
|
|
174
265
|
const task = (async () => {
|
|
175
266
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
} else if (source && typeof source[Symbol.asyncIterator] === 'function') {
|
|
183
|
-
let accumulated = '';
|
|
184
|
-
for await (const chunk of source) {
|
|
185
|
-
accumulated += chunk instanceof RawString
|
|
186
|
-
? chunk.value
|
|
187
|
-
: escape(chunk, ctx, attrName);
|
|
267
|
+
const build = (async () => {
|
|
268
|
+
let source = fragment ? fragment.source : val;
|
|
269
|
+
if (typeof source === 'function') source = source({ signal });
|
|
270
|
+
|
|
271
|
+
if (source && source.type === 'VecStream') {
|
|
272
|
+
return drainStream(renderToStream(source, nonce, options));
|
|
188
273
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
274
|
+
if (source && typeof source[Symbol.asyncIterator] === 'function') {
|
|
275
|
+
let accumulated = '';
|
|
276
|
+
for await (const chunk of source) {
|
|
277
|
+
accumulated += chunk instanceof RawString
|
|
278
|
+
? chunk.value
|
|
279
|
+
: escape(chunk, ctx, attrName);
|
|
280
|
+
}
|
|
281
|
+
return accumulated;
|
|
282
|
+
}
|
|
283
|
+
return valueToHtml(await source, ctx, attrName, nonce, options);
|
|
284
|
+
})();
|
|
285
|
+
build.catch(() => {}); // a lost timeout race must not warn
|
|
193
286
|
|
|
194
|
-
|
|
287
|
+
const html = await withTimeout(build, fragment && fragment.timeout, slotId);
|
|
288
|
+
enqueue(fillPatch(slotId, html, false));
|
|
195
289
|
} catch (err) {
|
|
196
290
|
console.error(
|
|
197
|
-
`[InertJS] Deferred fragment in slot ${slotId}
|
|
291
|
+
`[InertJS] Deferred fragment in slot ${slotId} failed after the shell was flushed:`,
|
|
198
292
|
err
|
|
199
293
|
);
|
|
200
294
|
if (onError) {
|
|
@@ -206,11 +300,11 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
206
300
|
}
|
|
207
301
|
let boundary = '';
|
|
208
302
|
try {
|
|
209
|
-
boundary = await errorBoundaryHtml(fragment, err, ctx, attrName, nonce);
|
|
303
|
+
boundary = await errorBoundaryHtml(fragment, err, slotId, ctx, attrName, nonce, options);
|
|
210
304
|
} catch (boundaryErr) {
|
|
211
305
|
console.error('[InertJS] Fragment error boundary failed:', boundaryErr);
|
|
212
306
|
}
|
|
213
|
-
enqueue(fillPatch(slotId, boundary,
|
|
307
|
+
enqueue(fillPatch(slotId, boundary, true));
|
|
214
308
|
} finally {
|
|
215
309
|
pendingTasks.delete(task);
|
|
216
310
|
if (pendingTasks.size === 0) closeController();
|
|
@@ -231,6 +325,11 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
231
325
|
initialHtml += plan.statics[i + 1];
|
|
232
326
|
}
|
|
233
327
|
|
|
328
|
+
// One patcher for the whole document, only when something actually streams.
|
|
329
|
+
if (slotCounter > 0) {
|
|
330
|
+
initialHtml += bootstrapScript(nonce);
|
|
331
|
+
}
|
|
332
|
+
|
|
234
333
|
// Flush the synchronous initial shell
|
|
235
334
|
enqueue(initialHtml);
|
|
236
335
|
|
|
@@ -246,6 +345,10 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
|
|
|
246
345
|
/* already torn down */
|
|
247
346
|
}
|
|
248
347
|
}
|
|
348
|
+
},
|
|
349
|
+
cancel() {
|
|
350
|
+
// Consumer walked away: let in-flight fragment work abort itself.
|
|
351
|
+
abort.abort();
|
|
249
352
|
}
|
|
250
353
|
});
|
|
251
354
|
}
|
|
@@ -106,3 +106,97 @@ test('Out-of-order streaming: fragment errors after shell flush', async (t) => {
|
|
|
106
106
|
assert.strictEqual(await resolveToString(vec`<p>${failed}</p>`), '<p><i>oops</i></p>');
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
test('Out-of-order streaming: hardening (beta.8)', async (t) => {
|
|
111
|
+
await t.test('one bootstrap patcher script, patches are bare templates', async () => {
|
|
112
|
+
const a = Promise.resolve(raw('<p>a</p>'));
|
|
113
|
+
const b = Promise.resolve(raw('<p>b</p>'));
|
|
114
|
+
const html = await collect(renderToStream(vec`<div>${a}</div><div>${b}</div>`, 'nn'));
|
|
115
|
+
|
|
116
|
+
const scripts = html.match(/<script nonce="nn">/g) || [];
|
|
117
|
+
assert.strictEqual(scripts.length, 1, 'exactly one inline script');
|
|
118
|
+
assert.ok(html.includes('MutationObserver'), 'bootstrap installs a MutationObserver');
|
|
119
|
+
assert.match(html, /<template data-i-fill="1"><p>a<\/p><\/template>/);
|
|
120
|
+
assert.match(html, /<template data-i-fill="2"><p>b<\/p><\/template>/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await t.test('no patcher script when nothing streams', async () => {
|
|
124
|
+
const html = await collect(renderToStream(vec`<p>${Promise.resolve('x')}</p>`, 'z'));
|
|
125
|
+
// one slot -> one script; a fully sync template never reaches renderToStream's stream path
|
|
126
|
+
assert.ok(html.includes('<script nonce="z">'));
|
|
127
|
+
const sync = await collect(renderToStream(raw('<p>hi</p>'), 'z'));
|
|
128
|
+
assert.strictEqual(sync, '<p>hi</p>');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await t.test('defer timeout patches the error boundary and closes the stream', async () => {
|
|
132
|
+
const frag = defer(new Promise(() => {}), { timeout: 40, error: raw('<p>too slow</p>') });
|
|
133
|
+
const html = await collect(renderToStream(vec`<main>${frag}</main>`, 'n'), 2000);
|
|
134
|
+
assert.ok(html.includes('data-i-error="1"'));
|
|
135
|
+
assert.ok(html.includes('<p>too slow</p>'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await t.test('timeout surfaces as DeferTimeoutError on the onError hook', async () => {
|
|
139
|
+
const seen = [];
|
|
140
|
+
const frag = defer(new Promise(() => {}), { timeout: 30 });
|
|
141
|
+
await collect(renderToStream(vec`<main>${frag}</main>`, 'n', {
|
|
142
|
+
onError: (err) => seen.push(err)
|
|
143
|
+
}), 2000);
|
|
144
|
+
assert.strictEqual(seen.length, 1);
|
|
145
|
+
assert.strictEqual(seen[0].code, 'E_INERT_FRAGMENT_TIMEOUT');
|
|
146
|
+
assert.strictEqual(seen[0].slot, 1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t.test('abort signal stops the stream', async () => {
|
|
150
|
+
const ac = new AbortController();
|
|
151
|
+
const frag = defer(new Promise(() => {}), { fallback: raw('<i>loading</i>') });
|
|
152
|
+
const reader = renderToStream(vec`<main>${frag}</main>`, 'n', { signal: ac.signal }).getReader();
|
|
153
|
+
|
|
154
|
+
const shell = decoder.decode((await reader.read()).value);
|
|
155
|
+
assert.ok(shell.includes('i-slot-1'));
|
|
156
|
+
ac.abort();
|
|
157
|
+
assert.strictEqual((await reader.read()).done, true, 'closed after abort');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await t.test('a pre-aborted signal yields an immediately-closed stream', async () => {
|
|
161
|
+
const ac = new AbortController();
|
|
162
|
+
ac.abort();
|
|
163
|
+
const reader = renderToStream(vec`<main>${Promise.resolve('x')}</main>`, 'n', { signal: ac.signal }).getReader();
|
|
164
|
+
assert.strictEqual((await reader.read()).done, true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await t.test('fragment factory receives an abort signal', async () => {
|
|
168
|
+
let received;
|
|
169
|
+
const frag = defer(({ signal }) => { received = signal; return Promise.resolve(raw('ok')); });
|
|
170
|
+
await collect(renderToStream(vec`<main>${frag}</main>`, 'n'));
|
|
171
|
+
assert.ok(received && typeof received.aborted === 'boolean');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await t.test('dev mode: a boundary-less failure renders a visible marker', async () => {
|
|
175
|
+
const prev = process.env.NODE_ENV;
|
|
176
|
+
delete process.env.NODE_ENV;
|
|
177
|
+
try {
|
|
178
|
+
const bad = Promise.reject(new Error('kaboom <x>'));
|
|
179
|
+
bad.catch(() => {});
|
|
180
|
+
const html = await collect(renderToStream(vec`<main>${bad}</main>`, 'n'));
|
|
181
|
+
assert.ok(html.includes('data-inert-fragment-error'));
|
|
182
|
+
assert.ok(html.includes('kaboom <x>'), 'error message is escaped');
|
|
183
|
+
} finally {
|
|
184
|
+
if (prev !== undefined) process.env.NODE_ENV = prev;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await t.test('production mode: a boundary-less failure renders nothing visible', async () => {
|
|
189
|
+
const prev = process.env.NODE_ENV;
|
|
190
|
+
process.env.NODE_ENV = 'production';
|
|
191
|
+
try {
|
|
192
|
+
const bad = Promise.reject(new Error('kaboom'));
|
|
193
|
+
bad.catch(() => {});
|
|
194
|
+
const html = await collect(renderToStream(vec`<main>${bad}</main>`, 'n'));
|
|
195
|
+
assert.ok(html.includes('data-i-fill="1" data-i-error="1"'));
|
|
196
|
+
assert.ok(!html.includes('data-inert-fragment-error'));
|
|
197
|
+
assert.match(html, /<template data-i-fill="1" data-i-error="1"><\/template>/);
|
|
198
|
+
} finally {
|
|
199
|
+
if (prev !== undefined) process.env.NODE_ENV = prev; else delete process.env.NODE_ENV;
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|