@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.
@@ -8,6 +8,9 @@ import './webjs-frame.js';
8
8
  // live-channel `connectWS` handler.
9
9
  import './webjs-stream.js';
10
10
  import { renderStream } from './webjs-stream.js';
11
+ // Register <webjs-suspense> (the element-level streaming boundary, #471) so it
12
+ // is layout-neutral and available for the progressive soft-nav streaming apply.
13
+ import './webjs-suspense.js';
11
14
 
12
15
  /** The content type a content-negotiated stream-action response carries (#248). */
13
16
  const STREAM_MIME = 'text/vnd.webjs-stream.html';
@@ -1438,6 +1441,10 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1438
1441
  method = method || 'GET';
1439
1442
  const myToken = typeof token === 'number' ? token : currentNavigationToken;
1440
1443
  let html;
1444
+ // Set when the response streams Suspense boundaries (#473): holds the open
1445
+ // reader + leftover buffer so the boundaries apply progressively after the
1446
+ // shell swap. Null for a buffered (non-streaming) or prefetched response.
1447
+ let streamCtx = null;
1441
1448
  let incomingBuild = null;
1442
1449
  /** @type {number | null} */
1443
1450
  let respStatus = null;
@@ -1556,7 +1563,13 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1556
1563
  // tag to compare. The applySwap importmap-mismatch guard reads
1557
1564
  // this to detect deploys that bumped the vendor pin.
1558
1565
  incomingBuild = resp.headers.get('x-webjs-build');
1559
- html = await resp.text();
1566
+ // Progressive streaming (#473): read only up to the first streamed Suspense
1567
+ // boundary so the shell (with fallbacks) swaps in immediately; the rest
1568
+ // streams in after the swap. A body with no boundaries reads to completion,
1569
+ // so a non-streaming nav is identical to the old `resp.text()`.
1570
+ const shellRead = await readStreamedShell(resp);
1571
+ html = shellRead.shell;
1572
+ if (shellRead.streaming) streamCtx = shellRead;
1560
1573
  }
1561
1574
  } catch (err) {
1562
1575
  // Aborted by a newer navigation: let it run, don't fall back. An
@@ -1577,7 +1590,10 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1577
1590
 
1578
1591
  // A newer navigation started while we awaited the response body -
1579
1592
  // bail before we overwrite its work.
1580
- if (myToken !== currentNavigationToken) return { ok: false, status: respStatus, aborted: true };
1593
+ if (myToken !== currentNavigationToken) {
1594
+ if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
1595
+ return { ok: false, status: respStatus, aborted: true };
1596
+ }
1581
1597
 
1582
1598
  const doc = parseHTML(html);
1583
1599
  // The body claimed text/html but didn't parse into a document (a
@@ -1610,6 +1626,20 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1610
1626
  }
1611
1627
  }
1612
1628
 
1629
+ // Progressive streaming (#473): the shell (with its Suspense fallbacks) is
1630
+ // now live, so stream the resolved boundaries in fast-before-slow. Detached
1631
+ // (fire-and-forget) so the URL advance + navigate event do not wait on the
1632
+ // slow boundary; each apply is guarded by the nav token so a newer navigation
1633
+ // stops it.
1634
+ if (streamCtx && (streamCtx.reader || streamCtx.rest)) {
1635
+ streamBoundariesProgressively(
1636
+ streamCtx.reader,
1637
+ streamCtx.dec,
1638
+ streamCtx.rest,
1639
+ () => myToken === currentNavigationToken,
1640
+ );
1641
+ }
1642
+
1613
1643
  document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
1614
1644
  return { ok: respOk, status: respStatus, aborted: false };
1615
1645
  } finally {
@@ -2832,6 +2862,160 @@ function forwardSuspenseResolvers(fetchedBody) {
2832
2862
  }
2833
2863
  }
2834
2864
 
2865
+ /**
2866
+ * Read a navigation response body progressively (#473). Returns the SHELL
2867
+ * (the HTML up to the first streamed Suspense boundary template) as soon as it
2868
+ * is available, so the router can swap it in immediately and the user sees the
2869
+ * fallbacks without waiting for the slow boundary. When the body carries
2870
+ * streamed boundaries it also returns the still-open `reader` + leftover buffer
2871
+ * so the caller applies each boundary progressively AFTER the shell swap. A body
2872
+ * with no boundaries reads to completion and returns the whole thing, so a
2873
+ * non-streaming navigation is behaviourally identical to `resp.text()`.
2874
+ *
2875
+ * @param {Response} resp
2876
+ * @returns {Promise<{ shell: string, streaming: boolean, reader?: ReadableStreamDefaultReader<Uint8Array>, dec?: TextDecoder, rest?: string }>}
2877
+ */
2878
+ async function readStreamedShell(resp) {
2879
+ if (!resp.body || typeof resp.body.getReader !== 'function') {
2880
+ return { shell: await resp.text(), streaming: false };
2881
+ }
2882
+ const reader = resp.body.getReader();
2883
+ const dec = new TextDecoder();
2884
+ let buf = '';
2885
+ const MARK = '<template data-webjs-resolve';
2886
+ // The SSR stream flushes the whole shell (prefix + body with fallbacks)
2887
+ // followed by a `<!--wj-stream-shell-->` sentinel in the SAME chunk, then
2888
+ // PAUSES for the slow data before streaming each boundary template and the
2889
+ // `</body></html>` closer. The sentinel is what lets the shell swap in
2890
+ // immediately instead of blocking until the slow boundary arrives. Fallbacks
2891
+ // for robustness: an already-buffered boundary marker (a fast boundary), or
2892
+ // `</html>` (a fully-buffered response that happens to carry boundaries).
2893
+ const SHELL = '<!--wj-stream-shell-->';
2894
+ const HTML_CLOSE = /<\/html\s*>/i;
2895
+ for (;;) {
2896
+ const { value, done } = await reader.read();
2897
+ if (value) buf += dec.decode(value, { stream: true });
2898
+ if (done) buf += dec.decode();
2899
+ const si = buf.indexOf(SHELL);
2900
+ if (si !== -1) {
2901
+ return { shell: buf.slice(0, si), streaming: true, reader: done ? null : reader, dec, rest: buf.slice(si + SHELL.length) };
2902
+ }
2903
+ const mi = buf.indexOf(MARK);
2904
+ if (mi !== -1) {
2905
+ return { shell: buf.slice(0, mi), streaming: true, reader: done ? null : reader, dec, rest: buf.slice(mi) };
2906
+ }
2907
+ if (done) {
2908
+ // Stream ended with no streaming markers: the whole body is the shell.
2909
+ return { shell: buf, streaming: false };
2910
+ }
2911
+ const hm = HTML_CLOSE.exec(buf);
2912
+ if (hm) {
2913
+ const end = hm.index + hm[0].length;
2914
+ return { shell: buf.slice(0, end), streaming: true, reader, dec, rest: buf.slice(end) };
2915
+ }
2916
+ }
2917
+ }
2918
+
2919
+ /**
2920
+ * Extract the next complete top-level
2921
+ * `<template data-webjs-resolve="ID">...</template>` unit from `buf`,
2922
+ * depth-tracking NESTED `<template>` tags (a streamed shadow component carries a
2923
+ * `<template shadowrootmode>` inside). Returns `{ id, content, rest }` for the
2924
+ * first complete unit, or null when the closing tag has not streamed in yet.
2925
+ *
2926
+ * @param {string} buf
2927
+ * @returns {{ id: string, content: string, rest: string } | null}
2928
+ */
2929
+ function takeResolveUnit(buf) {
2930
+ const m = /<template\s+data-webjs-resolve="([^"]+)"\s*>/i.exec(buf);
2931
+ if (!m) return null;
2932
+ const id = m[1];
2933
+ const contentStart = m.index + m[0].length;
2934
+ const tagRe = /<(\/?)template\b[^>]*>/gi;
2935
+ tagRe.lastIndex = contentStart;
2936
+ let depth = 1;
2937
+ let mm;
2938
+ while ((mm = tagRe.exec(buf))) {
2939
+ if (mm[1] === '/') {
2940
+ depth--;
2941
+ if (depth === 0) {
2942
+ return { id, content: buf.slice(contentStart, mm.index), rest: buf.slice(mm.index + mm[0].length) };
2943
+ }
2944
+ } else {
2945
+ depth++;
2946
+ }
2947
+ }
2948
+ return null;
2949
+ }
2950
+
2951
+ /**
2952
+ * Apply one streamed Suspense resolution to the live DOM (#473). REPLACES the
2953
+ * boundary element (its fallback) with the resolved content and upgrades any
2954
+ * custom elements inside. This mirrors the initial-load boot resolver
2955
+ * (`b.replaceWith(template.content)`) and the prefetched-buffered path exactly,
2956
+ * so a streamed boundary settles to the SAME DOM shape (the transient
2957
+ * `<webjs-boundary>` / `<webjs-suspense>` wrapper removed) however the page was
2958
+ * reached, in JS so a soft-nav apply does not depend on the inline swap script.
2959
+ *
2960
+ * @param {string} id
2961
+ * @param {string} content
2962
+ */
2963
+ function applyStreamedResolve(id, content) {
2964
+ const boundary = document.getElementById(id);
2965
+ if (!boundary) return;
2966
+ const tpl = document.createElement('template');
2967
+ tpl.innerHTML = content;
2968
+ const inserted = [...tpl.content.childNodes];
2969
+ boundary.replaceWith(tpl.content);
2970
+ // Upgrade any custom elements now that they are connected (belt-and-braces:
2971
+ // a connected, defined element upgrades on insertion, but a fragment that was
2972
+ // built before its module loaded would not).
2973
+ for (const n of inserted) if (n.nodeType === 1) upgradeTree(/** @type {Element} */ (n));
2974
+ }
2975
+
2976
+ /**
2977
+ * Progressively apply streamed Suspense boundaries from an open response reader
2978
+ * to the live DOM AFTER the shell has been swapped in (#473). Runs detached
2979
+ * (fire-and-forget); each apply is guarded by `isCurrent` so a newer navigation
2980
+ * stops it (and cancels the reader). A mid-stream transport failure leaves the
2981
+ * already-applied boundaries in place and the rest showing their fallback,
2982
+ * which is non-destructive.
2983
+ *
2984
+ * @param {ReadableStreamDefaultReader<Uint8Array>} reader
2985
+ * @param {TextDecoder} dec
2986
+ * @param {string} initialBuf
2987
+ * @param {() => boolean} isCurrent
2988
+ */
2989
+ async function streamBoundariesProgressively(reader, dec, initialBuf, isCurrent) {
2990
+ let buf = initialBuf;
2991
+ const flush = () => {
2992
+ let unit;
2993
+ while ((unit = takeResolveUnit(buf))) {
2994
+ if (!isCurrent()) return false;
2995
+ applyStreamedResolve(unit.id, unit.content);
2996
+ buf = unit.rest;
2997
+ }
2998
+ return true;
2999
+ };
3000
+ // The whole response was already buffered (the stream ended before the shell
3001
+ // delimiter): just apply whatever boundaries are in hand.
3002
+ if (!reader) { flush(); return; }
3003
+ try {
3004
+ for (;;) {
3005
+ if (!flush()) { try { await reader.cancel(); } catch { /* ignore */ } return; }
3006
+ const { value, done } = await reader.read();
3007
+ if (value) buf += dec.decode(value, { stream: true });
3008
+ if (done) {
3009
+ buf += dec.decode();
3010
+ flush();
3011
+ return;
3012
+ }
3013
+ }
3014
+ } catch {
3015
+ /* transport drop mid-stream: leave applied boundaries + remaining fallbacks */
3016
+ }
3017
+ }
3018
+
2835
3019
  /** @param {Element} container */
2836
3020
  function reactivateScripts(container) {
2837
3021
  for (const old of container.querySelectorAll('script')) {
@@ -2879,6 +3063,10 @@ export {
2879
3063
  prefetch as _prefetch,
2880
3064
  prefetchTake as _prefetchTake,
2881
3065
  prefetchSaysSaveData as _prefetchSaysSaveData,
3066
+ readStreamedShell as _readStreamedShell,
3067
+ takeResolveUnit as _takeResolveUnit,
3068
+ applyStreamedResolve as _applyStreamedResolve,
3069
+ streamBoundariesProgressively as _streamBoundariesProgressively,
2882
3070
  };
2883
3071
 
2884
3072
  /** Test-only: peek the speculative cache for a href without consuming it. */
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `<webjs-suspense>`: the client side of the element-level streaming boundary
3
+ * (#471).
4
+ *
5
+ * The SSR pipeline (render-server.js) does the real work: in a streaming
6
+ * render each `<webjs-suspense .fallback=${...}>` flushes its fallback
7
+ * immediately as `<webjs-suspense id="sN">FALLBACK</webjs-suspense>` and streams
8
+ * the resolved children later as a `<template data-webjs-resolve="sN">` plus a
9
+ * tiny inline swap `<script>` (the same mechanism page-level `Suspense` uses).
10
+ * On initial load that swap script REPLACES the boundary element with the
11
+ * resolved children (the transient `<webjs-suspense id>` wrapper is removed),
12
+ * and the custom elements inside upgrade natively. So NO client runtime is
13
+ * required for first-load streaming.
14
+ *
15
+ * This element exists for two reasons:
16
+ *
17
+ * 1. Layout neutrality. An unknown element defaults to `display: inline`,
18
+ * which would wrap a block-level fallback/content oddly. `display:
19
+ * contents` makes the boundary box disappear so its children lay out as
20
+ * if the wrapper were not there.
21
+ * 2. A registration home. The client router imports this module so the
22
+ * element is defined app-wide; the SOFT-NAVIGATION streaming path
23
+ * (router-client.js) reuses the same `data-webjs-resolve` swap to apply a
24
+ * streamed response progressively (#473).
25
+ *
26
+ * SSR-inert: `renderToString` emits the plain `<webjs-suspense>` tag and never
27
+ * touches this class (it is defined only when `HTMLElement` exists).
28
+ */
29
+ const WebjsSuspense = (typeof HTMLElement !== 'undefined')
30
+ ? class WebjsSuspense extends HTMLElement {
31
+ connectedCallback() {
32
+ // Layout-transparent wrapper. Set inline so no stylesheet is needed.
33
+ if (!this.style.display) this.style.display = 'contents';
34
+ }
35
+ }
36
+ : /** @type {any} */ (null);
37
+
38
+ if (typeof customElements !== 'undefined' && WebjsSuspense && !customElements.get('webjs-suspense')) {
39
+ customElements.define('webjs-suspense', WebjsSuspense);
40
+ }
41
+
42
+ export { WebjsSuspense };