@webjsdev/core 0.7.15 → 0.7.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -19
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +6 -3
- package/dist/webjs-core.js.map +3 -3
- package/package.json +1 -1
- package/src/component.d.ts +7 -0
- package/src/component.js +185 -29
- package/src/render-server.js +233 -16
- package/src/router-client.js +190 -2
- package/src/webjs-suspense.js +42 -0
package/src/router-client.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 };
|