@webjsdev/core 0.7.15 → 0.7.16
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 +198 -8
- 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
|
|
|
@@ -267,6 +267,19 @@ async function renderTemplate(tr, ctx) {
|
|
|
267
267
|
// page-level `.prop` on a native element could have set the
|
|
268
268
|
// property to begin with.
|
|
269
269
|
out = out.slice(0, attrStart);
|
|
270
|
+
// `<webjs-suspense .fallback=${html`...`}>` (#471). A TemplateResult
|
|
271
|
+
// is not serializer-safe (the normal data-webjs-prop-* path would
|
|
272
|
+
// drop it) and a normal custom-element prop applies only at
|
|
273
|
+
// hydration, too late for the streaming placeholder. So render the
|
|
274
|
+
// fallback to HTML now and carry it as data-webjs-fallback, which the
|
|
275
|
+
// injectDSD streaming pre-pass reads as the boundary placeholder.
|
|
276
|
+
if (currentTag === 'webjs-suspense' && name === 'fallback') {
|
|
277
|
+
const fbHtml = await render(val, ctx);
|
|
278
|
+
out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
|
|
279
|
+
state = 'in-tag';
|
|
280
|
+
attrName = '';
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
270
283
|
if (!currentTag.includes('-')) {
|
|
271
284
|
state = 'in-tag';
|
|
272
285
|
attrName = '';
|
|
@@ -353,6 +366,30 @@ function browserMemberHint(e) {
|
|
|
353
366
|
return null;
|
|
354
367
|
}
|
|
355
368
|
|
|
369
|
+
/** True in a production build (no dev error surfacing). */
|
|
370
|
+
function isProd() {
|
|
371
|
+
return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Default component-scoped error state for an async/sync render that threw
|
|
376
|
+
* during SSR, used when the component does not define renderError() (#469).
|
|
377
|
+
* Dev surfaces the tag + message loudly so the failure is obvious; prod
|
|
378
|
+
* renders an empty (silent, isolated) element so no internal detail leaks.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} tag
|
|
381
|
+
* @param {Error} err
|
|
382
|
+
* @returns {unknown} a TemplateResult (dev) or '' (prod)
|
|
383
|
+
*/
|
|
384
|
+
function defaultSSRErrorTemplate(tag, err) {
|
|
385
|
+
if (isProd()) return '';
|
|
386
|
+
const msg = err && err.message ? err.message : String(err);
|
|
387
|
+
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">
|
|
388
|
+
<strong><${tag}> failed to render</strong>
|
|
389
|
+
<div style="margin-top:4px;white-space:pre-wrap">${msg}</div>
|
|
390
|
+
</div>`;
|
|
391
|
+
}
|
|
392
|
+
|
|
356
393
|
/**
|
|
357
394
|
* Scan an HTML string for registered custom elements and inject
|
|
358
395
|
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
@@ -363,6 +400,12 @@ function browserMemberHint(e) {
|
|
|
363
400
|
* @returns {Promise<string>}
|
|
364
401
|
*/
|
|
365
402
|
async function injectDSD(html, ctx, ancestors = []) {
|
|
403
|
+
// Resolve <webjs-suspense> boundaries first (#471): in a streaming context
|
|
404
|
+
// each becomes a fallback placeholder now, with its children pushed for
|
|
405
|
+
// out-of-order streaming; without a streaming context the children render
|
|
406
|
+
// inline (blocking). Run before the custom-element walk so a streamed
|
|
407
|
+
// boundary's children leave the main flow and are not double-processed.
|
|
408
|
+
html = await processSuspenseElements(html, ctx, ancestors);
|
|
366
409
|
const tags = allTags();
|
|
367
410
|
if (!tags.length) return html;
|
|
368
411
|
// Sort longest tag name first so the regex alternation tries the most
|
|
@@ -388,9 +431,12 @@ async function injectDSD(html, ctx, ancestors = []) {
|
|
|
388
431
|
// `<link rel="modulepreload">` hints for their module URLs.
|
|
389
432
|
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
390
433
|
let opening = selfClose ? `<${tag}${attrs}>` : match;
|
|
434
|
+
// Hoisted so the per-component error boundary (#469) can ask the failed
|
|
435
|
+
// instance for its renderError() output.
|
|
436
|
+
let instance = null;
|
|
391
437
|
try {
|
|
392
438
|
const isShadow = /** @type any */ (Cls).shadow === true;
|
|
393
|
-
|
|
439
|
+
instance = new /** @type any */ (Cls)();
|
|
394
440
|
// Thread the ancestor chain (the enclosing custom-element instances)
|
|
395
441
|
// so the server element shim's closest() can resolve a parent at SSR.
|
|
396
442
|
// Set before performServerUpdate so a willUpdate() that reads a parent
|
|
@@ -521,6 +567,60 @@ async function injectDSD(html, ctx, ancestors = []) {
|
|
|
521
567
|
} else {
|
|
522
568
|
console.error(`[webjs] SSR failed for <${tag}>:`, e);
|
|
523
569
|
}
|
|
570
|
+
// Per-component error isolation (#469). A render that throws (most
|
|
571
|
+
// commonly a rejected `await getData()` in an async render, but any
|
|
572
|
+
// render throw) is caught HERE, per component: the loop continues so
|
|
573
|
+
// siblings render normally, and this element renders a component-scoped
|
|
574
|
+
// error state instead of bubbling to the route error.js or leaving its
|
|
575
|
+
// raw, unprocessed children in the output. renderError() customizes the
|
|
576
|
+
// error UI; the default surfaces the message in dev and renders an empty
|
|
577
|
+
// (silent, isolated) element in prod so no internal detail leaks.
|
|
578
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
579
|
+
let errorInner = '';
|
|
580
|
+
try {
|
|
581
|
+
let errTpl;
|
|
582
|
+
if (instance && typeof instance.renderError === 'function') {
|
|
583
|
+
errTpl = instance.renderError(err);
|
|
584
|
+
}
|
|
585
|
+
if (errTpl === undefined) errTpl = defaultSSRErrorTemplate(tag, err);
|
|
586
|
+
errorInner = await render(errTpl, ctx);
|
|
587
|
+
if (errorInner.trim()) {
|
|
588
|
+
errorInner = await injectDSD(errorInner, ctx, instance ? [...ancestors, instance] : ancestors);
|
|
589
|
+
}
|
|
590
|
+
} catch (renderErrorThrew) {
|
|
591
|
+
console.error(`[webjs] renderError() for <${tag}> also threw:`, renderErrorThrew);
|
|
592
|
+
errorInner = '';
|
|
593
|
+
}
|
|
594
|
+
// Replace the element (opening tag through its matching close) with the
|
|
595
|
+
// error state plus a hydration marker, so the client error boundary
|
|
596
|
+
// (component.js renderError) can take over on hydration.
|
|
597
|
+
let closeEnd = m.index + match.length;
|
|
598
|
+
if (!selfClose) {
|
|
599
|
+
const innerStart = m.index + match.length;
|
|
600
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
601
|
+
if (closeIdx !== -1) {
|
|
602
|
+
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
603
|
+
const cm = closeRe.exec(html.slice(closeIdx));
|
|
604
|
+
closeEnd = closeIdx + (cm ? cm[0].length : `</${tag}>`.length);
|
|
605
|
+
} else {
|
|
606
|
+
closeEnd = html.length;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// A shadow component renders into a shadow root on the client, so its
|
|
610
|
+
// SSR error state must ride a DSD template too (matching the success
|
|
611
|
+
// path), not land in light DOM. Otherwise the client renders the error
|
|
612
|
+
// into the shadow root while the light error box lingers underneath.
|
|
613
|
+
const isShadowErr = /** @type any */ (Cls).shadow === true;
|
|
614
|
+
let text;
|
|
615
|
+
if (isShadowErr) {
|
|
616
|
+
const rawStyles = /** @type any */ (Cls).styles;
|
|
617
|
+
const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
|
|
618
|
+
const styleStr = stylesToString(styleList);
|
|
619
|
+
text = `${opening}<template shadowrootmode="open">${styleStr}${errorInner}</template>`;
|
|
620
|
+
} else {
|
|
621
|
+
text = `${opening}<!--webjs-hydrate-->${errorInner}</${tag}>`;
|
|
622
|
+
}
|
|
623
|
+
edits.push({ start: m.index, end: closeEnd, text });
|
|
524
624
|
}
|
|
525
625
|
}
|
|
526
626
|
if (!edits.length) return html;
|
|
@@ -571,10 +671,79 @@ function isVoidElement(tag) {
|
|
|
571
671
|
}
|
|
572
672
|
|
|
573
673
|
/**
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
674
|
+
* Resolve `<webjs-suspense>` boundaries in an HTML string (#471). For each
|
|
675
|
+
* top-level boundary (nested ones are handled by the recursive injectDSD that
|
|
676
|
+
* processes a boundary's streamed children):
|
|
677
|
+
*
|
|
678
|
+
* - Streaming (a SuspenseCtx is present): emit the boundary as
|
|
679
|
+
* `<webjs-suspense id="sN">FALLBACK</webjs-suspense>` and push the raw inner
|
|
680
|
+
* children to `ctx.pending` (wrapped in `unsafeHTML` so the streaming pass
|
|
681
|
+
* renders them as HTML, not escaped text, then runs injectDSD over them).
|
|
682
|
+
* `streamSuspenseBoundaries` later streams the resolved children as a
|
|
683
|
+
* `<template data-webjs-resolve="sN">` plus the swap script. Multiple
|
|
684
|
+
* boundaries resolve via `Promise.all`, so their data fetches run
|
|
685
|
+
* concurrently. The placeholder is the boundary's `.fallback`
|
|
686
|
+
* (carried as `data-webjs-fallback`); a boundary without one shows empty.
|
|
687
|
+
* - Blocking (no ctx): render the children inline now and drop the fallback,
|
|
688
|
+
* so a non-streaming `renderToString` returns the real content. The
|
|
689
|
+
* `<webjs-suspense>` wrapper stays as an inert inline element.
|
|
690
|
+
*
|
|
691
|
+
* @param {string} html
|
|
692
|
+
* @param {SuspenseCtx} [ctx]
|
|
693
|
+
* @param {any[]} [ancestors]
|
|
694
|
+
* @returns {Promise<string>}
|
|
695
|
+
*/
|
|
696
|
+
async function processSuspenseElements(html, ctx, ancestors = []) {
|
|
697
|
+
if (html.indexOf('<webjs-suspense') === -1) return html;
|
|
698
|
+
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
|
|
699
|
+
let result = '';
|
|
700
|
+
let rest = html;
|
|
701
|
+
// Bounded loop: each iteration consumes at least the opening tag.
|
|
702
|
+
for (let guard = 0; guard < 10000; guard++) {
|
|
703
|
+
const m = OPEN.exec(rest);
|
|
704
|
+
if (!m) {
|
|
705
|
+
result += rest;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
const openStart = m.index;
|
|
709
|
+
const openEnd = m.index + m[0].length;
|
|
710
|
+
result += rest.slice(0, openStart);
|
|
711
|
+
const attrs = m[1] || '';
|
|
712
|
+
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
713
|
+
const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
|
|
714
|
+
|
|
715
|
+
const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense');
|
|
716
|
+
let inner;
|
|
717
|
+
let afterClose;
|
|
718
|
+
if (closeIdx === -1) {
|
|
719
|
+
inner = rest.slice(openEnd);
|
|
720
|
+
afterClose = '';
|
|
721
|
+
} else {
|
|
722
|
+
inner = rest.slice(openEnd, closeIdx);
|
|
723
|
+
const cm = /<\/webjs-suspense\s*>/i.exec(rest.slice(closeIdx));
|
|
724
|
+
afterClose = rest.slice(closeIdx + (cm ? cm[0].length : '</webjs-suspense>'.length));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (ctx) {
|
|
728
|
+
const id = `s${ctx.nextId++}`;
|
|
729
|
+
// Raw children stream in later. unsafeHTML so the streaming pass emits
|
|
730
|
+
// the markup verbatim (then injectDSD runs over it, resolving the async
|
|
731
|
+
// components and any nested boundaries).
|
|
732
|
+
ctx.pending.push({ id, promise: Promise.resolve(unsafeHTML(inner)) });
|
|
733
|
+
result += `<webjs-suspense id="${id}">${fallbackHtml}</webjs-suspense>`;
|
|
734
|
+
} else {
|
|
735
|
+
const innerProcessed = await injectDSD(inner, ctx, ancestors);
|
|
736
|
+
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
|
|
737
|
+
}
|
|
738
|
+
rest = afterClose;
|
|
739
|
+
}
|
|
740
|
+
return result;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Find the position of the matching closing tag for `tagName` starting from
|
|
745
|
+
* `fromIndex` in `html`. Handles nested same-tag elements via depth tracking.
|
|
746
|
+
* Returns the index of the `<` of `</tagName>`, or -1 if unclosed.
|
|
578
747
|
*
|
|
579
748
|
* @param {string} html
|
|
580
749
|
* @param {number} fromIndex
|
|
@@ -1259,13 +1428,34 @@ async function streamSuspenseBoundaries(ctx, controller) {
|
|
|
1259
1428
|
`(function(){` +
|
|
1260
1429
|
`var t=document.currentScript.previousElementSibling;` +
|
|
1261
1430
|
`var b=document.getElementById("${id}");` +
|
|
1262
|
-
`if(b&&t){b.
|
|
1431
|
+
`if(b&&t){b.replaceWith(t.content.cloneNode(true));t.remove()}` +
|
|
1263
1432
|
`document.currentScript.remove()` +
|
|
1264
1433
|
`})()` +
|
|
1265
1434
|
`</script>`
|
|
1266
1435
|
);
|
|
1267
1436
|
} catch (err) {
|
|
1268
1437
|
console.error(`[webjs] Suspense boundary "${id}" rejected:`, err);
|
|
1438
|
+
// Render a boundary-scoped error state rather than leaving the
|
|
1439
|
+
// fallback stuck forever (#471). Dev surfaces the message; prod
|
|
1440
|
+
// renders a silent empty element (no leak). A failure HERE (the
|
|
1441
|
+
// error render itself throwing) leaves the fallback in place.
|
|
1442
|
+
try {
|
|
1443
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
1444
|
+
const errHtml = await injectDSD(await render(defaultSSRErrorTemplate('webjs-suspense', e), ctx), ctx);
|
|
1445
|
+
controller.enqueue(
|
|
1446
|
+
`<template data-webjs-resolve="${id}">${errHtml}</template>` +
|
|
1447
|
+
`<script${nonceAttr}>` +
|
|
1448
|
+
`(function(){` +
|
|
1449
|
+
`var t=document.currentScript.previousElementSibling;` +
|
|
1450
|
+
`var b=document.getElementById("${id}");` +
|
|
1451
|
+
`if(b&&t){b.replaceWith(t.content.cloneNode(true));t.remove()}` +
|
|
1452
|
+
`document.currentScript.remove()` +
|
|
1453
|
+
`})()` +
|
|
1454
|
+
`</script>`
|
|
1455
|
+
);
|
|
1456
|
+
} catch (errorRenderThrew) {
|
|
1457
|
+
console.error(`[webjs] Suspense boundary "${id}" error render also threw:`, errorRenderThrew);
|
|
1458
|
+
}
|
|
1269
1459
|
}
|
|
1270
1460
|
})
|
|
1271
1461
|
);
|