@webjsdev/core 0.7.15 → 0.7.17
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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -19
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +6 -3
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/component.d.ts +7 -0
- package/src/component.js +185 -29
- package/src/render-server.js +233 -16
- package/src/router-client.js +190 -2
- package/src/webjs-suspense.js +42 -0
package/package.json
CHANGED
package/src/component.d.ts
CHANGED
|
@@ -113,6 +113,13 @@ export abstract class WebComponent extends HTMLElement {
|
|
|
113
113
|
firstUpdated?(changedProperties: Map<string, unknown>): void;
|
|
114
114
|
/** Optional render-error boundary inside the component. */
|
|
115
115
|
renderError?(error: Error): TemplateResult | void;
|
|
116
|
+
/**
|
|
117
|
+
* Optional loading UI for an async `render()` (#469), shown ONLY during a
|
|
118
|
+
* client re-fetch (a prop / dependency change re-runs `async render()`),
|
|
119
|
+
* NEVER on the first paint. The default re-fetch behaviour is
|
|
120
|
+
* stale-while-revalidate; define this to override it with a loading state.
|
|
121
|
+
*/
|
|
122
|
+
renderFallback?(): TemplateResult | void;
|
|
116
123
|
|
|
117
124
|
// Concrete on the base (component.js implements all three), so a subclass
|
|
118
125
|
// override can call `super.connectedCallback()` etc. without a
|
package/src/component.js
CHANGED
|
@@ -990,6 +990,9 @@ export class WebComponent extends Base {
|
|
|
990
990
|
// --- 1. Mark we're inside an update cycle ---
|
|
991
991
|
this._isUpdating = true;
|
|
992
992
|
let didCommit = false;
|
|
993
|
+
// Set when render() was async: the DOM commit lands when this settles,
|
|
994
|
+
// so the post-commit half of the cycle defers until then (#469).
|
|
995
|
+
let pendingCommit = null;
|
|
993
996
|
|
|
994
997
|
// --- 2-6. Update phase. Lifecycle-hook throws are logged and
|
|
995
998
|
// swallowed so the component is not left in a deadlocked state
|
|
@@ -1005,24 +1008,27 @@ export class WebComponent extends Base {
|
|
|
1005
1008
|
}
|
|
1006
1009
|
|
|
1007
1010
|
// --- 5. update + DOM commit (with render-error boundary) ---
|
|
1011
|
+
// Stamp a fresh render token for THIS commit, sync or async. A later
|
|
1012
|
+
// cycle that reaches here bumps it, so an in-flight async render whose
|
|
1013
|
+
// token no longer matches drops its now-stale resolution. Stamping
|
|
1014
|
+
// here (not inside _commitAsync) is what guards the async-superseded-
|
|
1015
|
+
// by-sync case (#469); a shouldUpdate=false cycle never reaches here,
|
|
1016
|
+
// so it does not invalidate an in-flight async render.
|
|
1017
|
+
this.__renderToken = (this.__renderToken || 0) + 1;
|
|
1008
1018
|
try {
|
|
1009
|
-
this.update(changedProperties);
|
|
1019
|
+
const r = this.update(changedProperties);
|
|
1020
|
+
if (r && typeof r.then === 'function') pendingCommit = r;
|
|
1010
1021
|
} catch (error) {
|
|
1011
|
-
|
|
1012
|
-
try {
|
|
1013
|
-
const fallback = this.renderError(/** @type {Error} */ (error));
|
|
1014
|
-
if (fallback !== undefined) clientRender(fallback, this._renderRoot);
|
|
1015
|
-
} catch (fallbackError) {
|
|
1016
|
-
console.error(`[webjs] renderError() also threw:`, fallbackError);
|
|
1017
|
-
}
|
|
1022
|
+
this._handleRenderError(/** @type {Error} */ (error));
|
|
1018
1023
|
}
|
|
1019
1024
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1025
|
+
if (!pendingCommit) {
|
|
1026
|
+
// --- 6. controllers' hostUpdated (sync commit) ---
|
|
1027
|
+
for (const c of this.__controllers) {
|
|
1028
|
+
if (c.hostUpdated) c.hostUpdated();
|
|
1029
|
+
}
|
|
1030
|
+
didCommit = true;
|
|
1023
1031
|
}
|
|
1024
|
-
|
|
1025
|
-
didCommit = true;
|
|
1026
1032
|
}
|
|
1027
1033
|
// shouldUpdate=false: preserve _changedProperties so the next
|
|
1028
1034
|
// requestUpdate keeps accumulating on top of the entries that
|
|
@@ -1031,28 +1037,46 @@ export class WebComponent extends Base {
|
|
|
1031
1037
|
console.error(`[webjs] lifecycle hook threw during update phase:`, preCommitError);
|
|
1032
1038
|
} finally {
|
|
1033
1039
|
this._isUpdating = false;
|
|
1034
|
-
|
|
1040
|
+
// The snapshot is consumed once the commit is DISPATCHED. For an async
|
|
1041
|
+
// render it resets here too, so a property change during the in-flight
|
|
1042
|
+
// fetch starts a fresh cycle (whose render token supersedes this one).
|
|
1043
|
+
if (didCommit || pendingCommit) {
|
|
1035
1044
|
this._changedProperties = new Map();
|
|
1036
1045
|
}
|
|
1037
1046
|
}
|
|
1038
1047
|
|
|
1039
|
-
// ---
|
|
1048
|
+
// --- Async render: finish the cycle when the pending commit settles. ---
|
|
1049
|
+
if (pendingCommit) {
|
|
1050
|
+
const token = this.__renderToken;
|
|
1051
|
+
// Count this in-flight async commit so a non-committing cycle
|
|
1052
|
+
// (shouldUpdate=false) running during the fetch does NOT resolve
|
|
1053
|
+
// updateComplete early: the pending commit owns the resolution.
|
|
1054
|
+
this.__pendingAsyncCommits = (this.__pendingAsyncCommits || 0) + 1;
|
|
1055
|
+
pendingCommit.then(() => {
|
|
1056
|
+
// Always decrement first, even when superseded, so the in-flight
|
|
1057
|
+
// count never leaks (a superseded cycle returns below without
|
|
1058
|
+
// committing, but it is no longer pending).
|
|
1059
|
+
this.__pendingAsyncCommits--;
|
|
1060
|
+
// A newer render superseded this one; let the newer cycle finish.
|
|
1061
|
+
if (token !== this.__renderToken) return;
|
|
1062
|
+
// --- 6. controllers' hostUpdated (after the async commit) ---
|
|
1063
|
+
for (const c of this.__controllers) {
|
|
1064
|
+
if (c.hostUpdated) c.hostUpdated();
|
|
1065
|
+
}
|
|
1066
|
+
// --- 7-8 + updateComplete ---
|
|
1067
|
+
this._postCommit(changedProperties);
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// --- 7-8. Post-commit hooks (sync path). Errors are caught so the
|
|
1040
1073
|
// updateComplete promise always resolves.
|
|
1041
1074
|
if (didCommit) {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
}
|
|
1048
|
-
// --- 8. updated (every render) ---
|
|
1049
|
-
this.updated(changedProperties);
|
|
1050
|
-
} catch (postCommitError) {
|
|
1051
|
-
console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
|
|
1052
|
-
} finally {
|
|
1053
|
-
this._resolveUpdate();
|
|
1054
|
-
}
|
|
1055
|
-
} else {
|
|
1075
|
+
this._postCommit(changedProperties);
|
|
1076
|
+
} else if (!this.__pendingAsyncCommits) {
|
|
1077
|
+
// A non-committing cycle resolves updateComplete only when no async
|
|
1078
|
+
// commit is in flight; otherwise the pending commit resolves it once it
|
|
1079
|
+
// lands, so `await el.updateComplete` never returns before that DOM.
|
|
1056
1080
|
this._resolveUpdate();
|
|
1057
1081
|
}
|
|
1058
1082
|
}
|
|
@@ -1203,7 +1227,110 @@ export class WebComponent extends Base {
|
|
|
1203
1227
|
}
|
|
1204
1228
|
let tpl;
|
|
1205
1229
|
this.__signalWatcher.observe(() => { tpl = this.render(); });
|
|
1230
|
+
// Bare-await async render (#469): render() returned a promise. Use
|
|
1231
|
+
// stale-while-revalidate: keep the current DOM (the SSR first paint on
|
|
1232
|
+
// hydration, or the prior content on a re-fetch) visible until the new
|
|
1233
|
+
// template resolves, then commit it. Returns the pending promise so
|
|
1234
|
+
// _performRender defers the post-commit half of the cycle (hostUpdated,
|
|
1235
|
+
// firstUpdated/updated, updateComplete) until the real commit lands.
|
|
1236
|
+
// (Note: only signal reads BEFORE the first `await` are tracked; reads
|
|
1237
|
+
// after a suspension point do not establish reactive dependencies.)
|
|
1238
|
+
if (tpl && typeof (/** @type any */ (tpl).then) === 'function') {
|
|
1239
|
+
return this._commitAsync(/** @type {Promise<unknown>} */ (tpl));
|
|
1240
|
+
}
|
|
1206
1241
|
clientRender(tpl, this._renderRoot);
|
|
1242
|
+
return undefined;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Commit a promise-returning render() with stale-while-revalidate
|
|
1247
|
+
* semantics (#469). The current DOM stays untouched until the template
|
|
1248
|
+
* resolves; on a client RE-FETCH an author-defined renderFallback()
|
|
1249
|
+
* optionally swaps in a loading state first. The cycle's render token
|
|
1250
|
+
* (stamped by _performRender before update() ran) drops a superseded
|
|
1251
|
+
* resolution so an out-of-order fetch never commits stale DOM, INCLUDING
|
|
1252
|
+
* when the superseding render is synchronous (a later cycle re-stamps the
|
|
1253
|
+
* token whether or not it is async). A rejection routes to the
|
|
1254
|
+
* renderError() boundary, isolated to this component. Returns a promise
|
|
1255
|
+
* that settles once the commit (or its error / fallback) has been applied.
|
|
1256
|
+
*
|
|
1257
|
+
* @param {Promise<unknown>} pending
|
|
1258
|
+
* @returns {Promise<void>}
|
|
1259
|
+
* @private
|
|
1260
|
+
*/
|
|
1261
|
+
_commitAsync(pending) {
|
|
1262
|
+
const token = this.__renderToken;
|
|
1263
|
+
// First paint (hydration): keep the SSR DOM, never a fallback, so
|
|
1264
|
+
// first-paint data stays visible with no skeleton flash. Re-fetch: an
|
|
1265
|
+
// author-defined renderFallback() overrides the stale-while-revalidate
|
|
1266
|
+
// default with an explicit loading state.
|
|
1267
|
+
if (this.__firstRendered && this._overridesRenderFallback()) {
|
|
1268
|
+
try {
|
|
1269
|
+
const fb = this.renderFallback();
|
|
1270
|
+
if (fb !== undefined) clientRender(fb, this._renderRoot);
|
|
1271
|
+
} catch (e) {
|
|
1272
|
+
console.error(`[webjs] renderFallback() threw:`, e);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return Promise.resolve(pending).then(
|
|
1276
|
+
(tpl) => {
|
|
1277
|
+
if (token !== this.__renderToken) return; // superseded by a newer render
|
|
1278
|
+
clientRender(tpl, this._renderRoot);
|
|
1279
|
+
},
|
|
1280
|
+
(error) => {
|
|
1281
|
+
if (token !== this.__renderToken) return;
|
|
1282
|
+
this._handleRenderError(error instanceof Error ? error : new Error(String(error)));
|
|
1283
|
+
},
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Whether the subclass overrides renderFallback() (vs the inert default).
|
|
1289
|
+
* @returns {boolean}
|
|
1290
|
+
* @private
|
|
1291
|
+
*/
|
|
1292
|
+
_overridesRenderFallback() {
|
|
1293
|
+
return this.renderFallback !== WebComponent.prototype.renderFallback;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Client-side render-error boundary, shared by the sync and async commit
|
|
1298
|
+
* paths. Logs, then commits renderError()'s output (if any) so one broken
|
|
1299
|
+
* component does not crash the page.
|
|
1300
|
+
* @param {Error} error
|
|
1301
|
+
* @private
|
|
1302
|
+
*/
|
|
1303
|
+
_handleRenderError(error) {
|
|
1304
|
+
console.error(
|
|
1305
|
+
`[webjs] render error in <${tagOf(/** @type any */ (this.constructor)) || this.tagName?.toLowerCase()}>:`,
|
|
1306
|
+
error,
|
|
1307
|
+
);
|
|
1308
|
+
try {
|
|
1309
|
+
const fallback = this.renderError(error);
|
|
1310
|
+
if (fallback !== undefined) clientRender(fallback, this._renderRoot);
|
|
1311
|
+
} catch (fallbackError) {
|
|
1312
|
+
console.error(`[webjs] renderError() also threw:`, fallbackError);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Post-commit half of the update cycle (steps 7-8 + updateComplete),
|
|
1318
|
+
* shared by the sync path and the async (post-resolution) path.
|
|
1319
|
+
* @param {Map<string, unknown>} changedProperties
|
|
1320
|
+
* @private
|
|
1321
|
+
*/
|
|
1322
|
+
_postCommit(changedProperties) {
|
|
1323
|
+
try {
|
|
1324
|
+
if (!this.__firstRendered) {
|
|
1325
|
+
this.__firstRendered = true;
|
|
1326
|
+
this.firstUpdated(changedProperties);
|
|
1327
|
+
}
|
|
1328
|
+
this.updated(changedProperties);
|
|
1329
|
+
} catch (postCommitError) {
|
|
1330
|
+
console.error(`[webjs] lifecycle hook threw during post-commit phase:`, postCommitError);
|
|
1331
|
+
} finally {
|
|
1332
|
+
this._resolveUpdate();
|
|
1333
|
+
}
|
|
1207
1334
|
}
|
|
1208
1335
|
|
|
1209
1336
|
/**
|
|
@@ -1374,6 +1501,35 @@ export class WebComponent extends Base {
|
|
|
1374
1501
|
renderError(error) {
|
|
1375
1502
|
return undefined;
|
|
1376
1503
|
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Optional loading UI for an async render() (#469), shown ONLY during a
|
|
1507
|
+
* client RE-FETCH (a prop / dependency change re-runs `async render()`),
|
|
1508
|
+
* NEVER on the first paint.
|
|
1509
|
+
*
|
|
1510
|
+
* **When to override (AI hint):** the client re-fetch default is
|
|
1511
|
+
* stale-while-revalidate (the component keeps showing its current content
|
|
1512
|
+
* until the new render resolves). Override `renderFallback()` only when you
|
|
1513
|
+
* want a loading state (skeleton / spinner) shown during the re-fetch
|
|
1514
|
+
* instead of the stale content, e.g. when stale data would mislead.
|
|
1515
|
+
*
|
|
1516
|
+
* **What it does NOT do:** it does not affect the first paint (SSR always
|
|
1517
|
+
* blocks and bakes real data in), and it does NOT create a server-streaming
|
|
1518
|
+
* boundary. To show a first-paint fallback for slow data, wrap the component
|
|
1519
|
+
* in `<webjs-suspense .fallback=${...}>` instead. It is a prop-aware method,
|
|
1520
|
+
* not a static field, so it can branch on the component's current state.
|
|
1521
|
+
*
|
|
1522
|
+
* ```js
|
|
1523
|
+
* renderFallback() { return html`<div class="skeleton h-24"></div>`; }
|
|
1524
|
+
* async render() { const u = await getUser(this.id); return html`<h3>${u.name}</h3>`; }
|
|
1525
|
+
* ```
|
|
1526
|
+
*
|
|
1527
|
+
* @returns {unknown} A TemplateResult loading state, or undefined to keep
|
|
1528
|
+
* the stale-while-revalidate default.
|
|
1529
|
+
*/
|
|
1530
|
+
renderFallback() {
|
|
1531
|
+
return undefined;
|
|
1532
|
+
}
|
|
1377
1533
|
}
|
|
1378
1534
|
|
|
1379
1535
|
/** @param {string} s */
|
package/src/render-server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { isTemplate } from './html.js';
|
|
1
|
+
import { html, isTemplate } from './html.js';
|
|
2
2
|
import { escapeText, escapeAttr } from './escape.js';
|
|
3
3
|
import { lookup, lookupModuleUrl, allTags } from './registry.js';
|
|
4
4
|
import { stylesToString, isCSS } from './css.js';
|
|
5
5
|
import { isRepeat } from './repeat.js';
|
|
6
6
|
import { isSuspense } from './suspense.js';
|
|
7
|
-
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
7
|
+
import { unsafeHTML, isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
8
8
|
import { stringify, parse } from './serialize.js';
|
|
9
9
|
import { cspNonce } from './csp-nonce.js';
|
|
10
10
|
|
|
@@ -30,8 +30,14 @@ import { cspNonce } from './csp-nonce.js';
|
|
|
30
30
|
*/
|
|
31
31
|
export async function renderToString(value, opts = { ssr: true }) {
|
|
32
32
|
const ctx = opts && opts.suspenseCtx;
|
|
33
|
+
// The server `dev` flag drives prod-silence of SSR error states (#483).
|
|
34
|
+
// `opts.dev` wins; else inherit a `dev` already stamped on the ctx (so a
|
|
35
|
+
// streamed sub-context inherits it); back-fill the ctx so downstream renders
|
|
36
|
+
// sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
|
|
37
|
+
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
|
|
38
|
+
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
|
|
33
39
|
const html = await render(value, ctx);
|
|
34
|
-
return opts && opts.ssr === false ? html : await injectDSD(html, ctx);
|
|
40
|
+
return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/**
|
|
@@ -267,6 +273,19 @@ async function renderTemplate(tr, ctx) {
|
|
|
267
273
|
// page-level `.prop` on a native element could have set the
|
|
268
274
|
// property to begin with.
|
|
269
275
|
out = out.slice(0, attrStart);
|
|
276
|
+
// `<webjs-suspense .fallback=${html`...`}>` (#471). A TemplateResult
|
|
277
|
+
// is not serializer-safe (the normal data-webjs-prop-* path would
|
|
278
|
+
// drop it) and a normal custom-element prop applies only at
|
|
279
|
+
// hydration, too late for the streaming placeholder. So render the
|
|
280
|
+
// fallback to HTML now and carry it as data-webjs-fallback, which the
|
|
281
|
+
// injectDSD streaming pre-pass reads as the boundary placeholder.
|
|
282
|
+
if (currentTag === 'webjs-suspense' && name === 'fallback') {
|
|
283
|
+
const fbHtml = await render(val, ctx);
|
|
284
|
+
out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
|
|
285
|
+
state = 'in-tag';
|
|
286
|
+
attrName = '';
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
270
289
|
if (!currentTag.includes('-')) {
|
|
271
290
|
state = 'in-tag';
|
|
272
291
|
attrName = '';
|
|
@@ -353,6 +372,39 @@ function browserMemberHint(e) {
|
|
|
353
372
|
return null;
|
|
354
373
|
}
|
|
355
374
|
|
|
375
|
+
/** True in a production build (no dev error surfacing). */
|
|
376
|
+
function isProd() {
|
|
377
|
+
return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Default component-scoped error state for an async/sync render that threw
|
|
382
|
+
* during SSR, used when the component does not define renderError() (#469).
|
|
383
|
+
* Dev surfaces the tag + message loudly so the failure is obvious; prod
|
|
384
|
+
* renders an empty (silent, isolated) element so no internal detail leaks.
|
|
385
|
+
*
|
|
386
|
+
* The prod-silence signal is the SERVER's `dev` flag, threaded through the SSR
|
|
387
|
+
* render context (#483). webjs keys prod on the CLI `dev` flag, not `NODE_ENV`,
|
|
388
|
+
* and `webjs start` does not export `NODE_ENV=production`, so a bare prod launch
|
|
389
|
+
* would otherwise leak the message. When `dev` is undefined (a context-free
|
|
390
|
+
* `renderToString` with no server signal, e.g. a bare unit test) it falls back
|
|
391
|
+
* to `isProd()` / `NODE_ENV`, preserving the prior behaviour for that path.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} tag
|
|
394
|
+
* @param {Error} err
|
|
395
|
+
* @param {boolean} [dev] server dev flag; undefined falls back to NODE_ENV
|
|
396
|
+
* @returns {unknown} a TemplateResult (dev) or '' (prod)
|
|
397
|
+
*/
|
|
398
|
+
function defaultSSRErrorTemplate(tag, err, dev) {
|
|
399
|
+
const surface = dev === undefined ? !isProd() : !!dev;
|
|
400
|
+
if (!surface) return '';
|
|
401
|
+
const msg = err && err.message ? err.message : String(err);
|
|
402
|
+
return html`<div data-webjs-error="${tag}" style="border:1px solid #f5c2c7;background:#f8d7da;color:#842029;padding:8px 12px;border-radius:6px;font:13px/1.4 system-ui,sans-serif">
|
|
403
|
+
<strong><${tag}> failed to render</strong>
|
|
404
|
+
<div style="margin-top:4px;white-space:pre-wrap">${msg}</div>
|
|
405
|
+
</div>`;
|
|
406
|
+
}
|
|
407
|
+
|
|
356
408
|
/**
|
|
357
409
|
* Scan an HTML string for registered custom elements and inject
|
|
358
410
|
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
@@ -360,9 +412,18 @@ function browserMemberHint(e) {
|
|
|
360
412
|
*
|
|
361
413
|
* @param {string} html
|
|
362
414
|
* @param {SuspenseCtx} [ctx]
|
|
415
|
+
* @param {any[]} [ancestors]
|
|
416
|
+
* @param {boolean} [dev] server dev flag, threaded to the per-component error
|
|
417
|
+
* template for prod-silence (#483); undefined falls back to NODE_ENV
|
|
363
418
|
* @returns {Promise<string>}
|
|
364
419
|
*/
|
|
365
|
-
async function injectDSD(html, ctx, ancestors = []) {
|
|
420
|
+
async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
421
|
+
// Resolve <webjs-suspense> boundaries first (#471): in a streaming context
|
|
422
|
+
// each becomes a fallback placeholder now, with its children pushed for
|
|
423
|
+
// out-of-order streaming; without a streaming context the children render
|
|
424
|
+
// inline (blocking). Run before the custom-element walk so a streamed
|
|
425
|
+
// boundary's children leave the main flow and are not double-processed.
|
|
426
|
+
html = await processSuspenseElements(html, ctx, ancestors, dev);
|
|
366
427
|
const tags = allTags();
|
|
367
428
|
if (!tags.length) return html;
|
|
368
429
|
// Sort longest tag name first so the regex alternation tries the most
|
|
@@ -388,9 +449,12 @@ async function injectDSD(html, ctx, ancestors = []) {
|
|
|
388
449
|
// `<link rel="modulepreload">` hints for their module URLs.
|
|
389
450
|
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
390
451
|
let opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
452
|
+
// Hoisted so the per-component error boundary (#469) can ask the failed
|
|
453
|
+
// instance for its renderError() output.
|
|
454
|
+
let instance = null;
|
|
391
455
|
try {
|
|
392
456
|
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
393
|
-
|
|
457
|
+
instance = new /** @type any */ (Cls)();
|
|
394
458
|
// Thread the ancestor chain (the enclosing custom-element instances)
|
|
395
459
|
// so the server element shim's closest() can resolve a parent at SSR.
|
|
396
460
|
// Set before performServerUpdate so a willUpdate() that reads a parent
|
|
@@ -441,7 +505,7 @@ async function injectDSD(html, ctx, ancestors = []) {
|
|
|
441
505
|
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
442
506
|
// browser handles projection from the host's light-DOM children
|
|
443
507
|
// into the shadow tree natively. No framework substitution here.
|
|
444
|
-
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance]);
|
|
508
|
+
const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance], dev);
|
|
445
509
|
const rawStyles = /** @type any */ (Cls).styles;
|
|
446
510
|
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
447
511
|
const styleStr = stylesToString(styleList);
|
|
@@ -504,7 +568,7 @@ async function injectDSD(html, ctx, ancestors = []) {
|
|
|
504
568
|
}
|
|
505
569
|
const partitioned = partitionAuthoredBySlot(authoredInner);
|
|
506
570
|
const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
|
|
507
|
-
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance]);
|
|
571
|
+
const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance], dev);
|
|
508
572
|
edits.push({
|
|
509
573
|
start: m.index,
|
|
510
574
|
end: closeEnd,
|
|
@@ -521,6 +585,60 @@ async function injectDSD(html, ctx, ancestors = []) {
|
|
|
521
585
|
} else {
|
|
522
586
|
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
523
587
|
}
|
|
588
|
+
// Per-component error isolation (#469). A render that throws (most
|
|
589
|
+
// commonly a rejected `await getData()` in an async render, but any
|
|
590
|
+
// render throw) is caught HERE, per component: the loop continues so
|
|
591
|
+
// siblings render normally, and this element renders a component-scoped
|
|
592
|
+
// error state instead of bubbling to the route error.js or leaving its
|
|
593
|
+
// raw, unprocessed children in the output. renderError() customizes the
|
|
594
|
+
// error UI; the default surfaces the message in dev and renders an empty
|
|
595
|
+
// (silent, isolated) element in prod so no internal detail leaks.
|
|
596
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
597
|
+
let errorInner = '';
|
|
598
|
+
try {
|
|
599
|
+
let errTpl;
|
|
600
|
+
if (instance && typeof instance.renderError === 'function') {
|
|
601
|
+
errTpl = instance.renderError(err);
|
|
602
|
+
}
|
|
603
|
+
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err, dev);
|
|
604
|
+
errorInner = await render(errTpl, ctx);
|
|
605
|
+
if (errorInner.trim()) {
|
|
606
|
+
errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors, dev);
|
|
607
|
+
}
|
|
608
|
+
} catch (renderErrorThrew) {
|
|
609
|
+
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
|
|
610
|
+
errorInner = '';
|
|
611
|
+
}
|
|
612
|
+
// Replace the element (opening tag through its matching close) with the
|
|
613
|
+
// error state plus a hydration marker, so the client error boundary
|
|
614
|
+
// (component.js renderError) can take over on hydration.
|
|
615
|
+
let closeEnd = m.index + match.length;
|
|
616
|
+
if (!selfClose) {
|
|
617
|
+
const innerStart = m.index + match.length;
|
|
618
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
619
|
+
if (closeIdx !== -1) {
|
|
620
|
+
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
621
|
+
const cm = closeRe.exec(html.slice(closeIdx));
|
|
622
|
+
closeEnd = closeIdx + (cm ? cm[0].length : `</${tag}>`.length);
|
|
623
|
+
} else {
|
|
624
|
+
closeEnd = html.length;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// A shadow component renders into a shadow root on the client, so its
|
|
628
|
+
// SSR error state must ride a DSD template too (matching the success
|
|
629
|
+
// path), not land in light DOM. Otherwise the client renders the error
|
|
630
|
+
// into the shadow root while the light error box lingers underneath.
|
|
631
|
+
const isShadowErr = /** @type any */ (Cls).shadow === true;
|
|
632
|
+
let text;
|
|
633
|
+
if (isShadowErr) {
|
|
634
|
+
const rawStyles = /** @type any */ (Cls).styles;
|
|
635
|
+
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
636
|
+
const styleStr = stylesToString(styleList);
|
|
637
|
+
text = `${opening}<template shadowrootmode="open">${styleStr}${errorInner}</template>`;
|
|
638
|
+
} else {
|
|
639
|
+
text = `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
|
|
640
|
+
}
|
|
641
|
+
edits.push({ start: m.index, end: closeEnd, text });
|
|
524
642
|
}
|
|
525
643
|
}
|
|
526
644
|
if (!edits.length) return html;
|
|
@@ -571,10 +689,81 @@ function isVoidElement(tag) {
|
|
|
571
689
|
}
|
|
572
690
|
|
|
573
691
|
/**
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
692
|
+
* Resolve `<webjs-suspense>` boundaries in an HTML string (#471). For each
|
|
693
|
+
* top-level boundary (nested ones are handled by the recursive injectDSD that
|
|
694
|
+
* processes a boundary's streamed children):
|
|
695
|
+
*
|
|
696
|
+
* - Streaming (a SuspenseCtx is present): emit the boundary as
|
|
697
|
+
* `<webjs-suspense id="sN">FALLBACK</webjs-suspense>` and push the raw inner
|
|
698
|
+
* children to `ctx.pending` (wrapped in `unsafeHTML` so the streaming pass
|
|
699
|
+
* renders them as HTML, not escaped text, then runs injectDSD over them).
|
|
700
|
+
* `streamSuspenseBoundaries` later streams the resolved children as a
|
|
701
|
+
* `<template data-webjs-resolve="sN">` plus the swap script. Multiple
|
|
702
|
+
* boundaries resolve via `Promise.all`, so their data fetches run
|
|
703
|
+
* concurrently. The placeholder is the boundary's `.fallback`
|
|
704
|
+
* (carried as `data-webjs-fallback`); a boundary without one shows empty.
|
|
705
|
+
* - Blocking (no ctx): render the children inline now and drop the fallback,
|
|
706
|
+
* so a non-streaming `renderToString` returns the real content. The
|
|
707
|
+
* `<webjs-suspense>` wrapper stays as an inert inline element.
|
|
708
|
+
*
|
|
709
|
+
* @param {string} html
|
|
710
|
+
* @param {SuspenseCtx} [ctx]
|
|
711
|
+
* @param {any[]} [ancestors]
|
|
712
|
+
* @param {boolean} [dev] server dev flag for prod-silence of a throwing
|
|
713
|
+
* component in an inline (ctx-absent) boundary (#483)
|
|
714
|
+
* @returns {Promise<string>}
|
|
715
|
+
*/
|
|
716
|
+
async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
717
|
+
if (html.indexOf('<webjs-suspense') === -1) return html;
|
|
718
|
+
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
|
|
719
|
+
let result = '';
|
|
720
|
+
let rest = html;
|
|
721
|
+
// Bounded loop: each iteration consumes at least the opening tag.
|
|
722
|
+
for (let guard = 0; guard < 10000; guard++) {
|
|
723
|
+
const m = OPEN.exec(rest);
|
|
724
|
+
if (!m) {
|
|
725
|
+
result += rest;
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
const openStart = m.index;
|
|
729
|
+
const openEnd = m.index + m[0].length;
|
|
730
|
+
result += rest.slice(0, openStart);
|
|
731
|
+
const attrs = m[1] || '';
|
|
732
|
+
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
733
|
+
const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
|
|
734
|
+
|
|
735
|
+
const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense');
|
|
736
|
+
let inner;
|
|
737
|
+
let afterClose;
|
|
738
|
+
if (closeIdx === -1) {
|
|
739
|
+
inner = rest.slice(openEnd);
|
|
740
|
+
afterClose = '';
|
|
741
|
+
} else {
|
|
742
|
+
inner = rest.slice(openEnd, closeIdx);
|
|
743
|
+
const cm = /<\/webjs-suspense\s*>/i.exec(rest.slice(closeIdx));
|
|
744
|
+
afterClose = rest.slice(closeIdx + (cm ? cm[0].length : '</webjs-suspense>'.length));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (ctx) {
|
|
748
|
+
const id = `s${ctx.nextId++}`;
|
|
749
|
+
// Raw children stream in later. unsafeHTML so the streaming pass emits
|
|
750
|
+
// the markup verbatim (then injectDSD runs over it, resolving the async
|
|
751
|
+
// components and any nested boundaries).
|
|
752
|
+
ctx.pending.push({ id, promise: Promise.resolve(unsafeHTML(inner)) });
|
|
753
|
+
result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;
|
|
754
|
+
} else {
|
|
755
|
+
const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
|
|
756
|
+
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
|
|
757
|
+
}
|
|
758
|
+
rest = afterClose;
|
|
759
|
+
}
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Find the position of the matching closing tag for `tagName` starting from
|
|
765
|
+
* `fromIndex` in `html`. Handles nested same-tag elements via depth tracking.
|
|
766
|
+
* Returns the index of the `<` of `</tagName>`, or -1 if unclosed.
|
|
578
767
|
*
|
|
579
768
|
* @param {string} html
|
|
580
769
|
* @param {number} fromIndex
|
|
@@ -961,6 +1150,11 @@ function consumePropAttrs(attrs) {
|
|
|
961
1150
|
*/
|
|
962
1151
|
export function renderToStream(value, opts = { ssr: true }) {
|
|
963
1152
|
const ctx = opts && opts.suspenseCtx;
|
|
1153
|
+
// Server dev flag for prod-silence of SSR error states (#483), same sourcing
|
|
1154
|
+
// as renderToString: opts.dev wins, else inherit from the ctx, else undefined
|
|
1155
|
+
// (NODE_ENV fallback). Back-fill the ctx so the streamed sub-renders share it.
|
|
1156
|
+
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
|
|
1157
|
+
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
|
|
964
1158
|
return new ReadableStream({
|
|
965
1159
|
async start(controller) {
|
|
966
1160
|
try {
|
|
@@ -972,13 +1166,13 @@ export function renderToStream(value, opts = { ssr: true }) {
|
|
|
972
1166
|
// the full HTML), then enqueue the result. This matches the
|
|
973
1167
|
// semantics of renderToString but still gives us a stream.
|
|
974
1168
|
const html = await render(value, ctx);
|
|
975
|
-
const full = await injectDSD(html, ctx);
|
|
1169
|
+
const full = await injectDSD(html, ctx, [], dev);
|
|
976
1170
|
controller.enqueue(full);
|
|
977
1171
|
}
|
|
978
1172
|
|
|
979
1173
|
// Stream resolved Suspense boundaries after the main content.
|
|
980
1174
|
if (ctx && ctx.pending.length) {
|
|
981
|
-
await streamSuspenseBoundaries(ctx, controller);
|
|
1175
|
+
await streamSuspenseBoundaries(ctx, controller, dev);
|
|
982
1176
|
}
|
|
983
1177
|
controller.close();
|
|
984
1178
|
} catch (err) {
|
|
@@ -1235,8 +1429,10 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1235
1429
|
*
|
|
1236
1430
|
* @param {SuspenseCtx} ctx
|
|
1237
1431
|
* @param {ReadableStreamDefaultController<string>} controller
|
|
1432
|
+
* @param {boolean} [dev] server dev flag for prod-silence of a rejected
|
|
1433
|
+
* streamed boundary (#483); undefined falls back to NODE_ENV
|
|
1238
1434
|
*/
|
|
1239
|
-
async function streamSuspenseBoundaries(ctx, controller) {
|
|
1435
|
+
async function streamSuspenseBoundaries(ctx, controller, dev) {
|
|
1240
1436
|
// Resolve the per-request nonce once per call. The provider in
|
|
1241
1437
|
// @webjsdev/server sources it from AsyncLocalStorage; outside a
|
|
1242
1438
|
// request scope (or in the browser) the helper returns '' and we
|
|
@@ -1252,20 +1448,41 @@ async function streamSuspenseBoundaries(ctx, controller) {
|
|
|
1252
1448
|
try {
|
|
1253
1449
|
const resolved = await promise;
|
|
1254
1450
|
const html = await render(resolved, ctx);
|
|
1255
|
-
const full = await injectDSD(html, ctx);
|
|
1451
|
+
const full = await injectDSD(html, ctx, [], dev);
|
|
1256
1452
|
controller.enqueue(
|
|
1257
1453
|
`<template data-webjs-resolve="${id}">${full}</template>` +
|
|
1258
1454
|
`<script${nonceAttr}>` +
|
|
1259
1455
|
`(function(){` +
|
|
1260
1456
|
`var t=document.currentScript.previousElementSibling;` +
|
|
1261
1457
|
`var b=document.getElementById("${id}");` +
|
|
1262
|
-
`if(b&&t){b.
|
|
1458
|
+
`if(b&&t){b.replaceWith(t.content.cloneNode(true));t.remove()}` +
|
|
1263
1459
|
`document.currentScript.remove()` +
|
|
1264
1460
|
`})()` +
|
|
1265
1461
|
`</script>`
|
|
1266
1462
|
);
|
|
1267
1463
|
} catch (err) {
|
|
1268
1464
|
console.error(`[webjs] Suspense boundary "${id}" rejected:`, err);
|
|
1465
|
+
// Render a boundary-scoped error state rather than leaving the
|
|
1466
|
+
// fallback stuck forever (#471). Dev surfaces the message; prod
|
|
1467
|
+
// renders a silent empty element (no leak). A failure HERE (the
|
|
1468
|
+
// error render itself throwing) leaves the fallback in place.
|
|
1469
|
+
try {
|
|
1470
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
1471
|
+
const errHtml = await injectDSD(await render(defaultSSRErrorTemplate('webjs-suspense', e, dev), ctx), ctx, [], dev);
|
|
1472
|
+
controller.enqueue(
|
|
1473
|
+
`<template data-webjs-resolve="${id}">${errHtml}</template>` +
|
|
1474
|
+
`<script${nonceAttr}>` +
|
|
1475
|
+
`(function(){` +
|
|
1476
|
+
`var t=document.currentScript.previousElementSibling;` +
|
|
1477
|
+
`var b=document.getElementById("${id}");` +
|
|
1478
|
+
`if(b&&t){b.replaceWith(t.content.cloneNode(true));t.remove()}` +
|
|
1479
|
+
`document.currentScript.remove()` +
|
|
1480
|
+
`})()` +
|
|
1481
|
+
`</script>`
|
|
1482
|
+
);
|
|
1483
|
+
} catch (errorRenderThrew) {
|
|
1484
|
+
console.error(`[webjs] Suspense boundary "${id}" error render also threw:`, errorRenderThrew);
|
|
1485
|
+
}
|
|
1269
1486
|
}
|
|
1270
1487
|
})
|
|
1271
1488
|
);
|