@webjsdev/core 0.7.14 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -75,7 +75,7 @@
75
75
  "prepare": "node -e \"require('fs').existsSync('../../scripts/build-framework-dist.js') && require('fs').existsSync('./src/html.js') && require('child_process').execSync('npm run build:dist', {stdio:'inherit'})\""
76
76
  },
77
77
  "devDependencies": {
78
- "esbuild": "^0.24.0"
78
+ "esbuild": "^0.25.0"
79
79
  },
80
80
  "publishConfig": {
81
81
  "access": "public"
@@ -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 */
package/src/nav.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export function notFound(): never;
2
- export function redirect(url: string, status?: number): never;
2
+ export function redirect(url: string, status?: number | { status?: number }): never;
3
3
  export function isNotFound(e: unknown): boolean;
4
4
  export function isRedirect(e: unknown): boolean;
package/src/nav.js CHANGED
@@ -22,15 +22,34 @@ export function notFound() {
22
22
  }
23
23
 
24
24
  /**
25
+ * Throw a redirect sentinel the SSR pipeline catches and turns into a 3xx.
26
+ *
27
+ * The status is OPTIONAL and, when omitted, is chosen by the catching site so
28
+ * each kind of redirect gets the conventional code:
29
+ * - thrown during a GET page/layout render (a gating redirect like an auth
30
+ * bounce to `/login`): 302 Found, the conventional GET-to-GET code.
31
+ * - thrown from a server action (a POST): 307 Temporary Redirect, which is
32
+ * method-preserving so the action's intent survives. (The PRG success
33
+ * path uses 303 separately.)
34
+ * Pass an explicit status to override: `redirect('/x', 308)` (permanent) or the
35
+ * options form `redirect('/x', { status: 301 })`. An explicit status wins at
36
+ * every catching site.
37
+ *
25
38
  * @param {string} url
26
- * @param {number} [status] 307 (temp) by default; pass 308 for permanent
39
+ * @param {number | { status?: number }} [status] explicit status (number) or
40
+ * `{ status }`; omit to let the catching site pick (302 for a GET gate, 307
41
+ * for an action).
27
42
  * @returns {never}
28
43
  */
29
- export function redirect(url, status = 307) {
44
+ export function redirect(url, status) {
45
+ const code = typeof status === 'object' && status !== null ? status.status : status;
30
46
  const err = new Error(`webjs: redirect(${url})`);
31
47
  /** @type any */ (err).__webjs = REDIRECT;
32
48
  /** @type any */ (err).url = url;
33
- /** @type any */ (err).status = status;
49
+ // Left undefined when the caller did not specify one, so the catching site
50
+ // can apply its convention (302 GET gate vs 307 action). A caller-supplied
51
+ // code is stored verbatim and overrides that convention.
52
+ /** @type any */ (err).status = typeof code === 'number' ? code : undefined;
34
53
  throw err;
35
54
  }
36
55
 
@@ -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>&lt;${tag}&gt; 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
- const instance = new /** @type any */ (Cls)();
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
- * 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.
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.innerHTML=t.innerHTML;t.remove()}` +
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
  );