@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -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
- console.error(`[webjs] render error in <${tagOf(/** @type any */ (this.constructor)) || this.tagName?.toLowerCase()}>:`, error);
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
- // --- 6. controllers' hostUpdated ---
1021
- for (const c of this.__controllers) {
1022
- if (c.hostUpdated) c.hostUpdated();
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
- if (didCommit) {
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
- // --- 7-8. Post-commit hooks. Errors here are also caught so the
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
- try {
1043
- // --- 7. firstUpdated (once) ---
1044
- if (!this.__firstRendered) {
1045
- this.__firstRendered = true;
1046
- this.firstUpdated(changedProperties);
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 */
@@ -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>&lt;${tag}&gt; 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
- const instance = new /** @type any */ (Cls)();
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
- * Find the position of the matching closing tag for `tagName` starting
575
- * from `fromIndex` in `html`. Handles nested same-tag elements via depth
576
- * tracking. Returns the index of the `<` of `</tagName>`, or -1 if
577
- * unclosed.
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.innerHTML=t.innerHTML;t.remove()}` +
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
  );