@microsoft/webui-framework 0.0.18 → 0.0.20
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/README.md +130 -70
- package/dist/decorators.d.ts +1 -0
- package/dist/decorators.js +3 -0
- package/dist/element/diff.d.ts +3 -1
- package/dist/element/diff.js +197 -90
- package/dist/element/markers.js +13 -5
- package/dist/element/types.d.ts +18 -10
- package/dist/element/types.js +3 -0
- package/dist/element.d.ts +0 -4
- package/dist/element.js +11 -69
- package/dist/hydration-mismatch.js +3 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +17 -0
- package/dist/lifecycle.js +66 -3
- package/dist/static-host.js +20 -10
- package/dist/streaming-activation.d.ts +2 -0
- package/dist/streaming-activation.js +13 -0
- package/dist/streaming-bootstrap.d.ts +2 -0
- package/dist/streaming-bootstrap.js +48 -0
- package/dist/streaming-cleanup.d.ts +4 -0
- package/dist/streaming-cleanup.js +66 -0
- package/dist/streaming-coordinator.d.ts +7 -0
- package/dist/streaming-coordinator.js +367 -0
- package/dist/streaming-deferred.d.ts +22 -0
- package/dist/streaming-deferred.js +273 -0
- package/dist/streaming-dom.d.ts +32 -0
- package/dist/streaming-dom.js +148 -0
- package/dist/streaming-entry.d.ts +1 -0
- package/dist/streaming-entry.js +2 -0
- package/dist/streaming-install.d.ts +2 -0
- package/dist/streaming-install.js +25 -0
- package/dist/streaming-mode.d.ts +5 -0
- package/dist/streaming-mode.js +14 -0
- package/dist/streaming-protocol.d.ts +32 -0
- package/dist/streaming-protocol.js +27 -0
- package/dist/streaming.d.ts +5 -0
- package/dist/streaming.js +9 -0
- package/dist/template-element.d.ts +28 -9
- package/dist/template-element.js +448 -238
- package/dist/template-events.js +1 -1
- package/dist/template-roots.d.ts +0 -1
- package/dist/template-roots.js +0 -11
- package/dist/template-types.d.ts +7 -1
- package/dist/template.d.ts +2 -0
- package/dist/template.js +46 -19
- package/package.json +9 -3
package/dist/lifecycle.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
let pendingCount = 0;
|
|
2
2
|
let started = false;
|
|
3
3
|
let completed = false;
|
|
4
|
+
let streamingGateActive = false;
|
|
5
|
+
let terminalReached = false;
|
|
6
|
+
let streamingGateAborted = false;
|
|
7
|
+
let pendingBoundaries = 0;
|
|
8
|
+
let pendingLateActivations = 0;
|
|
4
9
|
export function hydrationStart() {
|
|
5
10
|
if (!started) {
|
|
6
11
|
performance.mark('webui:hydrate:total:start');
|
|
@@ -9,11 +14,69 @@ export function hydrationStart() {
|
|
|
9
14
|
pendingCount++;
|
|
10
15
|
}
|
|
11
16
|
export function hydrationEnd() {
|
|
17
|
+
if (pendingCount === 0)
|
|
18
|
+
return;
|
|
12
19
|
pendingCount--;
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
tryComplete();
|
|
21
|
+
}
|
|
22
|
+
export function beginStreamingGate() {
|
|
23
|
+
streamingGateActive = true;
|
|
24
|
+
}
|
|
25
|
+
export function abortStreamingGate() {
|
|
26
|
+
streamingGateAborted = true;
|
|
27
|
+
}
|
|
28
|
+
export function markBoundaryPending() {
|
|
29
|
+
pendingBoundaries++;
|
|
30
|
+
}
|
|
31
|
+
export function markBoundaryCommitted(terminal) {
|
|
32
|
+
if (pendingBoundaries === 0)
|
|
33
|
+
return;
|
|
34
|
+
pendingBoundaries--;
|
|
35
|
+
if (terminal)
|
|
36
|
+
terminalReached = true;
|
|
37
|
+
tryComplete();
|
|
38
|
+
}
|
|
39
|
+
export function markLateActivationPending() {
|
|
40
|
+
pendingLateActivations++;
|
|
41
|
+
}
|
|
42
|
+
export function settleLateActivation() {
|
|
43
|
+
if (pendingLateActivations === 0)
|
|
44
|
+
return;
|
|
45
|
+
pendingLateActivations--;
|
|
46
|
+
tryComplete();
|
|
47
|
+
}
|
|
48
|
+
function tryComplete() {
|
|
49
|
+
if (completed || streamingGateAborted || pendingCount !== 0)
|
|
50
|
+
return;
|
|
51
|
+
if (streamingGateActive &&
|
|
52
|
+
(!terminalReached || pendingBoundaries !== 0 || pendingLateActivations !== 0))
|
|
53
|
+
return;
|
|
54
|
+
completed = true;
|
|
55
|
+
if (started) {
|
|
15
56
|
performance.mark('webui:hydrate:total:end');
|
|
16
57
|
performance.measure('webui:hydrate:total', 'webui:hydrate:total:start', 'webui:hydrate:total:end');
|
|
17
|
-
window.dispatchEvent(new Event('webui:hydration-complete'));
|
|
18
58
|
}
|
|
59
|
+
window.dispatchEvent(new Event('webui:hydration-complete'));
|
|
60
|
+
}
|
|
61
|
+
export function __getLifecycleStateForTests() {
|
|
62
|
+
return {
|
|
63
|
+
pendingCount,
|
|
64
|
+
started,
|
|
65
|
+
completed,
|
|
66
|
+
streamingGateActive,
|
|
67
|
+
streamingGateAborted,
|
|
68
|
+
terminalReached,
|
|
69
|
+
pendingBoundaries,
|
|
70
|
+
pendingLateActivations,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function __resetLifecycleForTests() {
|
|
74
|
+
pendingCount = 0;
|
|
75
|
+
started = false;
|
|
76
|
+
completed = false;
|
|
77
|
+
streamingGateActive = false;
|
|
78
|
+
streamingGateAborted = false;
|
|
79
|
+
terminalReached = false;
|
|
80
|
+
pendingBoundaries = 0;
|
|
81
|
+
pendingLateActivations = 0;
|
|
19
82
|
}
|
package/dist/static-host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TemplateElement } from './template-element.js';
|
|
2
2
|
import { getTemplateRegistry } from './template.js';
|
|
3
|
-
import {
|
|
3
|
+
import { templateNeedsStaticHost } from './template-roots.js';
|
|
4
4
|
import { TEMPLATES_REGISTERED_EVENT, templateRegistrationDetail, } from './template-events.js';
|
|
5
5
|
let runtimeInstalled = false;
|
|
6
6
|
function defineTemplateHost(tag, meta) {
|
|
@@ -12,16 +12,28 @@ function defineTemplateHost(tag, meta) {
|
|
|
12
12
|
if (!w.__webui.templates[tag])
|
|
13
13
|
w.__webui.templates[tag] = meta;
|
|
14
14
|
class StaticTemplateHost extends TemplateElement {
|
|
15
|
-
$
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
$afterExternalStateWrite(applied) {
|
|
16
|
+
if (applied)
|
|
17
|
+
this.$activateDeferredSSR();
|
|
18
|
+
}
|
|
19
|
+
$shouldDeferSSRHydration() {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
$shouldApplySSRBootstrapState() {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
$shouldActivateOnBoundaryCommit() {
|
|
26
|
+
return false;
|
|
18
27
|
}
|
|
19
28
|
}
|
|
20
29
|
StaticTemplateHost.define(tag);
|
|
21
30
|
}
|
|
22
31
|
function defineMissingTemplateHost(tag, meta) {
|
|
23
|
-
if (!templateNeedsStaticHost(meta) ||
|
|
32
|
+
if (!templateNeedsStaticHost(meta) ||
|
|
33
|
+
window.__webui?.templateHostExclusions?.has(tag) ||
|
|
34
|
+
customElements.get(tag)) {
|
|
24
35
|
return;
|
|
36
|
+
}
|
|
25
37
|
defineTemplateHost(tag, meta);
|
|
26
38
|
}
|
|
27
39
|
function defineTemplateHosts(templates = getTemplateRegistry()) {
|
|
@@ -45,14 +57,12 @@ export function installTemplateElementRuntime() {
|
|
|
45
57
|
runtimeInstalled = true;
|
|
46
58
|
window.addEventListener(TEMPLATES_REGISTERED_EVENT, (event) => {
|
|
47
59
|
const detail = templateRegistrationDetail(event);
|
|
48
|
-
if (!detail)
|
|
60
|
+
if (!detail?.templates)
|
|
49
61
|
return;
|
|
50
|
-
|
|
51
|
-
defineTemplateHosts(detail.templates);
|
|
62
|
+
defineTemplateHosts(detail.templates);
|
|
52
63
|
});
|
|
64
|
+
defineTemplateHosts();
|
|
53
65
|
if (document.readyState === 'loading') {
|
|
54
66
|
document.addEventListener('DOMContentLoaded', () => defineTemplateHosts(), { once: true });
|
|
55
|
-
return;
|
|
56
67
|
}
|
|
57
|
-
defineTemplateHosts();
|
|
58
68
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { abandonDeferredRange, } from './streaming-cleanup.js';
|
|
2
|
+
import { activateDeferredTree, } from './streaming-deferred.js';
|
|
3
|
+
export function activateRootsBetween(startMarker, endMarker, state, updates) {
|
|
4
|
+
const root = startMarker.parentNode;
|
|
5
|
+
if (!root) {
|
|
6
|
+
throw new Error('boundary start marker was detached before activation');
|
|
7
|
+
}
|
|
8
|
+
const failure = activateDeferredTree(startMarker.nextSibling, root, endMarker, state, updates ? { updates, retainedRoots: updates.roots } : undefined);
|
|
9
|
+
if (!failure)
|
|
10
|
+
return;
|
|
11
|
+
abandonDeferredRange(startMarker, endMarker);
|
|
12
|
+
throw new Error(failure);
|
|
13
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { registerTemplateData } from './template.js';
|
|
2
|
+
export function applyBoundaryBootstrap(bootstrap) {
|
|
3
|
+
if (bootstrap.templates)
|
|
4
|
+
registerTemplateData(bootstrap.templates);
|
|
5
|
+
const w = window;
|
|
6
|
+
if (!w.__webui)
|
|
7
|
+
w.__webui = {};
|
|
8
|
+
const keys = Object.keys(bootstrap);
|
|
9
|
+
for (let i = 0; i < keys.length; i++) {
|
|
10
|
+
const key = keys[i];
|
|
11
|
+
if (key === 'templates' || key === 'state')
|
|
12
|
+
continue;
|
|
13
|
+
if (key === 'inventory') {
|
|
14
|
+
w.__webui.inventory = mergeInventory(w.__webui.inventory, bootstrap.inventory);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (key === 'css' || key === 'styles') {
|
|
18
|
+
appendUniqueStrings(w.__webui, key, bootstrap[key]);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
w.__webui[key] = bootstrap[key];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function mergeInventory(existing, delta) {
|
|
25
|
+
const current = typeof existing === 'string' ? existing : '';
|
|
26
|
+
const next = delta ?? '';
|
|
27
|
+
const length = Math.max(current.length, next.length);
|
|
28
|
+
let merged = '';
|
|
29
|
+
for (let i = 0; i < length; i++) {
|
|
30
|
+
const a = i < current.length ? parseInt(current[i], 16) : 0;
|
|
31
|
+
const b = i < next.length ? parseInt(next[i], 16) : 0;
|
|
32
|
+
merged += (a | b).toString(16);
|
|
33
|
+
}
|
|
34
|
+
return merged;
|
|
35
|
+
}
|
|
36
|
+
function appendUniqueStrings(target, key, delta) {
|
|
37
|
+
if (!delta)
|
|
38
|
+
return;
|
|
39
|
+
const existing = target[key];
|
|
40
|
+
const cumulative = Array.isArray(existing) ? existing : [];
|
|
41
|
+
for (let i = 0; i < delta.length; i++) {
|
|
42
|
+
if (cumulative.indexOf(delta[i]) === -1) {
|
|
43
|
+
cumulative.push(delta[i]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (cumulative !== existing)
|
|
47
|
+
target[key] = cumulative;
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function abandonDeferredElement(el: Element): void;
|
|
2
|
+
export declare function abandonDeferredDescendants(root: Element): void;
|
|
3
|
+
export declare function abandonDeferredRange(startMarker: Comment, endMarker: Node): void;
|
|
4
|
+
export declare function abandonDeferredDocumentRoots(): void;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { firstNodeWithin, MAX_MARKER_SCAN_NODES, nextWithinRoot, safeRemoveAttribute, streamingErrorMessage, } from './streaming-dom.js';
|
|
2
|
+
import { STREAMED_HOST_ATTR } from './streaming-mode.js';
|
|
3
|
+
const STREAMING_BOUNDARY_ABANDON = Symbol.for('microsoft.webui.boundaryAbandon');
|
|
4
|
+
export function abandonDeferredElement(el) {
|
|
5
|
+
try {
|
|
6
|
+
if (!el.hasAttribute(STREAMED_HOST_ATTR))
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
safeRemoveAttribute(el, STREAMED_HOST_ATTR);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const hook = el[STREAMING_BOUNDARY_ABANDON];
|
|
15
|
+
if (typeof hook === 'function')
|
|
16
|
+
hook.call(el);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
console.error(`[WebUI] streaming: abandon failed for <${el.tagName?.toLowerCase?.() ?? '?'}>: ${streamingErrorMessage(error)}`);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
safeRemoveAttribute(el, STREAMED_HOST_ATTR);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function abandonDeferredDescendants(root) {
|
|
26
|
+
abandonDeferredNodes(firstNodeWithin(root), root, null);
|
|
27
|
+
}
|
|
28
|
+
export function abandonDeferredRange(startMarker, endMarker) {
|
|
29
|
+
const root = startMarker.parentNode;
|
|
30
|
+
if (root) {
|
|
31
|
+
abandonDeferredNodes(startMarker.nextSibling, root, endMarker);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function abandonDeferredNodes(first, root, end) {
|
|
35
|
+
let node = first;
|
|
36
|
+
let visited = 0;
|
|
37
|
+
while (node && node !== end && visited < MAX_MARKER_SCAN_NODES) {
|
|
38
|
+
visited++;
|
|
39
|
+
if (node.nodeType === 1) {
|
|
40
|
+
abandonDeferredElement(node);
|
|
41
|
+
}
|
|
42
|
+
node = nextWithinRoot(node, root);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function abandonDeferredDocumentRoots() {
|
|
46
|
+
if (typeof document === 'undefined' ||
|
|
47
|
+
typeof document.getElementsByTagName !== 'function') {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const elements = document.getElementsByTagName('*');
|
|
51
|
+
let visited = 0;
|
|
52
|
+
for (let i = 0; i < elements.length && visited < MAX_MARKER_SCAN_NODES; i++) {
|
|
53
|
+
const el = elements[i];
|
|
54
|
+
visited++;
|
|
55
|
+
abandonDeferredElement(el);
|
|
56
|
+
const shadowRoot = el.shadowRoot;
|
|
57
|
+
let node = shadowRoot?.firstChild ?? null;
|
|
58
|
+
while (node && visited < MAX_MARKER_SCAN_NODES) {
|
|
59
|
+
visited++;
|
|
60
|
+
if (node.nodeType === 1) {
|
|
61
|
+
abandonDeferredElement(node);
|
|
62
|
+
}
|
|
63
|
+
node = nextWithinRoot(node, shadowRoot);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { elementHasPendingStateForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests } from './streaming-deferred.js';
|
|
2
|
+
export declare function enqueueStreamingSentinel(sentinel: Element): void;
|
|
3
|
+
export declare function installStreamingTruncationGuard(): void;
|
|
4
|
+
export declare function resetStreamingCoordinatorStateForTests(): void;
|
|
5
|
+
export declare function isStreamingHaltedForTests(): boolean;
|
|
6
|
+
export declare function streamingRetentionStateForTests(): readonly [number, number];
|
|
7
|
+
export { elementHasPendingStateForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests, };
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { abortStreamingGate, markBoundaryCommitted, markBoundaryPending, } from './lifecycle.js';
|
|
2
|
+
import { activateRootsBetween, } from './streaming-activation.js';
|
|
3
|
+
import { applyBoundaryBootstrap } from './streaming-bootstrap.js';
|
|
4
|
+
import { abandonDeferredDocumentRoots, abandonDeferredRange, } from './streaming-cleanup.js';
|
|
5
|
+
import { abandonPendingWaiters, configureStreamingFailureHandler, elementHasPendingStateForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests, resetDeferredActivationForTests, } from './streaming-deferred.js';
|
|
6
|
+
import { findBoundaryScript, findEndMarkerByPrefix, findStartMarkerByPrefix, removeBoundaryScaffolding, resolveBoundaryRange, streamingErrorMessage, } from './streaming-dom.js';
|
|
7
|
+
import { parseBoundaryEnvelope, RECORD_KIND_STATE_UPDATE, RECORD_KIND_TERMINAL, RECORD_KIND_UPDATABLE_CHECKPOINT, } from './streaming-protocol.js';
|
|
8
|
+
const MAX_QUEUED_BOUNDARIES = 512;
|
|
9
|
+
const MAX_UPDATABLE_BOUNDARIES = 128;
|
|
10
|
+
const MAX_RETAINED_UPDATE_ROOTS = 50_000;
|
|
11
|
+
const BOUNDARY_HYDRATED_EVENT = 'webui:boundary-hydrated';
|
|
12
|
+
const BOUNDARY_MARK_PREFIX = 'webui:boundary:';
|
|
13
|
+
const UPDATE_MARK_SUFFIX = ':update';
|
|
14
|
+
const TERMINAL_MARK = 'webui:streaming:terminal';
|
|
15
|
+
const perf = globalThis.performance;
|
|
16
|
+
const queue = [];
|
|
17
|
+
let queueHead = 0;
|
|
18
|
+
let pumpScheduled = false;
|
|
19
|
+
let slicedDrainActive = false;
|
|
20
|
+
let halted = false;
|
|
21
|
+
let nextExpectedRecordSequence = 0;
|
|
22
|
+
let nextExpectedBoundaryId = 0;
|
|
23
|
+
let terminalCommitted = false;
|
|
24
|
+
let pendingTerminalSequence = null;
|
|
25
|
+
let terminalValidationScheduled = false;
|
|
26
|
+
let coordinatorGeneration = 0;
|
|
27
|
+
let retainedUpdateRoots = 0;
|
|
28
|
+
const updatableBoundaries = new Map();
|
|
29
|
+
configureStreamingFailureHandler(fail);
|
|
30
|
+
export function enqueueStreamingSentinel(sentinel) {
|
|
31
|
+
if (halted) {
|
|
32
|
+
discardRejectedBoundary(sentinel);
|
|
33
|
+
abandonDeferredDocumentRoots();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (queue.length - queueHead >= MAX_QUEUED_BOUNDARIES) {
|
|
37
|
+
failBoundary(sentinel, `queued boundary count exceeds ${MAX_QUEUED_BOUNDARIES}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
queue.push(sentinel);
|
|
41
|
+
if (!pumpScheduled) {
|
|
42
|
+
pumpScheduled = true;
|
|
43
|
+
queueMicrotask(drainQueue);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function drainQueue() {
|
|
47
|
+
const budget = sliceBudgetMs();
|
|
48
|
+
if (budget > 0) {
|
|
49
|
+
void drainSliced(budget);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
pumpScheduled = false;
|
|
53
|
+
while (queueHead < queue.length) {
|
|
54
|
+
const sentinel = queue[queueHead];
|
|
55
|
+
queueHead++;
|
|
56
|
+
if (!halted)
|
|
57
|
+
processSentinel(sentinel);
|
|
58
|
+
}
|
|
59
|
+
queue.length = 0;
|
|
60
|
+
queueHead = 0;
|
|
61
|
+
scheduleTerminalValidation();
|
|
62
|
+
}
|
|
63
|
+
function sliceBudgetMs() {
|
|
64
|
+
const raw = window
|
|
65
|
+
.__WEBUI_STREAMING_SLICE_MS__;
|
|
66
|
+
return typeof raw === 'number' && raw > 0 ? raw : 0;
|
|
67
|
+
}
|
|
68
|
+
function nowMs() {
|
|
69
|
+
return perf ? perf.now() : Date.now();
|
|
70
|
+
}
|
|
71
|
+
async function drainSliced(budget) {
|
|
72
|
+
const generation = coordinatorGeneration;
|
|
73
|
+
slicedDrainActive = true;
|
|
74
|
+
let deadline = nowMs() + budget;
|
|
75
|
+
while (queueHead < queue.length) {
|
|
76
|
+
const sentinel = queue[queueHead];
|
|
77
|
+
queueHead++;
|
|
78
|
+
if (!halted)
|
|
79
|
+
processSentinel(sentinel);
|
|
80
|
+
if (nowMs() >= deadline) {
|
|
81
|
+
await yieldToRenderer();
|
|
82
|
+
if (generation !== coordinatorGeneration) {
|
|
83
|
+
slicedDrainActive = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
deadline = nowMs() + budget;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
slicedDrainActive = false;
|
|
90
|
+
pumpScheduled = false;
|
|
91
|
+
queue.length = 0;
|
|
92
|
+
queueHead = 0;
|
|
93
|
+
scheduleTerminalValidation();
|
|
94
|
+
}
|
|
95
|
+
function yieldToRenderer() {
|
|
96
|
+
const scheduler = globalThis.scheduler;
|
|
97
|
+
if (scheduler?.yield)
|
|
98
|
+
return scheduler.yield();
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
setTimeout(resolve, 0);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function fail(reason) {
|
|
104
|
+
halted = true;
|
|
105
|
+
console.error(`[WebUI] streaming hydration halted: ${reason}.`);
|
|
106
|
+
abortStreamingGate();
|
|
107
|
+
abandonPendingWaiters();
|
|
108
|
+
clearUpdatableBoundaries();
|
|
109
|
+
settlePendingTerminal(false);
|
|
110
|
+
for (let i = queueHead; i < queue.length; i++) {
|
|
111
|
+
discardRejectedBoundary(queue[i]);
|
|
112
|
+
}
|
|
113
|
+
queue.length = 0;
|
|
114
|
+
queueHead = 0;
|
|
115
|
+
abandonDeferredDocumentRoots();
|
|
116
|
+
}
|
|
117
|
+
function failBoundary(sentinel, reason) {
|
|
118
|
+
discardRejectedBoundary(sentinel);
|
|
119
|
+
fail(reason);
|
|
120
|
+
}
|
|
121
|
+
function discardRejectedBoundary(sentinel) {
|
|
122
|
+
const scriptEl = findBoundaryScript(sentinel);
|
|
123
|
+
let endMarker = null;
|
|
124
|
+
let startMarker = null;
|
|
125
|
+
if (scriptEl) {
|
|
126
|
+
endMarker = findEndMarkerByPrefix(scriptEl);
|
|
127
|
+
if (endMarker)
|
|
128
|
+
startMarker = findStartMarkerByPrefix(endMarker);
|
|
129
|
+
}
|
|
130
|
+
if (startMarker && endMarker) {
|
|
131
|
+
abandonDeferredRange(startMarker, endMarker);
|
|
132
|
+
}
|
|
133
|
+
removeBoundaryScaffolding(sentinel, scriptEl, startMarker, endMarker);
|
|
134
|
+
}
|
|
135
|
+
function processSentinel(sentinel) {
|
|
136
|
+
if (terminalCommitted) {
|
|
137
|
+
failBoundary(sentinel, 'boundary arrived after the terminal streaming record');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const scriptEl = findBoundaryScript(sentinel);
|
|
141
|
+
if (!scriptEl) {
|
|
142
|
+
failBoundary(sentinel, 'missing boundary payload script before <webui-hydrate>');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const parsed = parseBoundaryEnvelope(scriptEl.textContent ?? '');
|
|
146
|
+
if (!parsed.ok) {
|
|
147
|
+
failBoundary(sentinel, parsed.reason);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const [, sequence, kind, target, payload] = parsed.envelope;
|
|
151
|
+
if (sequence !== nextExpectedRecordSequence) {
|
|
152
|
+
failBoundary(sentinel, `expected streaming record sequence ${nextExpectedRecordSequence}, received ${sequence}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (kind === RECORD_KIND_STATE_UPDATE) {
|
|
156
|
+
const boundary = updatableBoundaries.get(target);
|
|
157
|
+
if (!boundary) {
|
|
158
|
+
failBoundary(sentinel, `state update targets boundary ${target}, which is not committed as updatable`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
nextExpectedRecordSequence++;
|
|
162
|
+
commitStateUpdate(boundary, payload, target, sequence, sentinel, scriptEl);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (kind === RECORD_KIND_TERMINAL) {
|
|
166
|
+
const resolved = resolveBoundaryRange(scriptEl, 0, true);
|
|
167
|
+
if (!resolved.ok) {
|
|
168
|
+
failBoundary(sentinel, resolved.reason);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
nextExpectedRecordSequence++;
|
|
172
|
+
commitTerminal(sequence, sentinel, scriptEl);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (target !== nextExpectedBoundaryId) {
|
|
176
|
+
failBoundary(sentinel, `expected boundary ID ${nextExpectedBoundaryId}, received ${target}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const resolved = resolveBoundaryRange(scriptEl, target, false);
|
|
180
|
+
if (!resolved.ok) {
|
|
181
|
+
if (resolved.truncated) {
|
|
182
|
+
if (resolved.start) {
|
|
183
|
+
abandonDeferredRange(resolved.start, scriptEl);
|
|
184
|
+
}
|
|
185
|
+
removeBoundaryScaffolding(sentinel, scriptEl, resolved.start, null);
|
|
186
|
+
fail(resolved.reason);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
failBoundary(sentinel, resolved.reason);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
nextExpectedRecordSequence++;
|
|
194
|
+
nextExpectedBoundaryId++;
|
|
195
|
+
commitCheckpoint(payload, resolved.range, sequence, target, kind === RECORD_KIND_UPDATABLE_CHECKPOINT, sentinel, scriptEl);
|
|
196
|
+
}
|
|
197
|
+
function commitCheckpoint(bootstrap, range, sequence, target, updatable, sentinel, scriptEl) {
|
|
198
|
+
markBoundaryPending();
|
|
199
|
+
let committed = false;
|
|
200
|
+
try {
|
|
201
|
+
applyBoundaryBootstrap(bootstrap);
|
|
202
|
+
if (range.start && range.end) {
|
|
203
|
+
const boundary = updatable
|
|
204
|
+
? { roots: [], pendingRoots: 0 }
|
|
205
|
+
: undefined;
|
|
206
|
+
activateRootsBetween(range.start, range.end, bootstrap.state, boundary);
|
|
207
|
+
if (boundary)
|
|
208
|
+
retainUpdatableBoundary(target, boundary);
|
|
209
|
+
}
|
|
210
|
+
else if (updatable) {
|
|
211
|
+
retainUpdatableBoundary(target, { roots: [], pendingRoots: 0 });
|
|
212
|
+
}
|
|
213
|
+
committed = true;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
fail(`error committing boundary ${sequence}: ${streamingErrorMessage(error)}`);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
removeBoundaryScaffolding(sentinel, scriptEl, range.start, range.end);
|
|
220
|
+
if (committed) {
|
|
221
|
+
notifyCommit(`${BOUNDARY_MARK_PREFIX}${target}`, sequence, 'checkpoint');
|
|
222
|
+
}
|
|
223
|
+
markBoundaryCommitted(false);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function retainUpdatableBoundary(target, boundary) {
|
|
227
|
+
if (updatableBoundaries.size >= MAX_UPDATABLE_BOUNDARIES) {
|
|
228
|
+
throw new Error(`updatable boundary count exceeds ${MAX_UPDATABLE_BOUNDARIES}`);
|
|
229
|
+
}
|
|
230
|
+
if (retainedUpdateRoots + boundary.roots.length >
|
|
231
|
+
MAX_RETAINED_UPDATE_ROOTS) {
|
|
232
|
+
throw new Error(`retained update root count exceeds ${MAX_RETAINED_UPDATE_ROOTS}`);
|
|
233
|
+
}
|
|
234
|
+
retainedUpdateRoots += boundary.roots.length;
|
|
235
|
+
updatableBoundaries.set(target, boundary);
|
|
236
|
+
}
|
|
237
|
+
function commitStateUpdate(boundary, patch, target, sequence, sentinel, scriptEl) {
|
|
238
|
+
try {
|
|
239
|
+
if (boundary.pendingRoots > 0) {
|
|
240
|
+
if (boundary.patch) {
|
|
241
|
+
Object.assign(boundary.patch, patch);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
boundary.patch = Object.assign(Object.create(null), patch);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (let i = 0; i < boundary.roots.length; i++) {
|
|
248
|
+
const root = boundary.roots[i];
|
|
249
|
+
if (root.hasAttribute('data-ws'))
|
|
250
|
+
continue;
|
|
251
|
+
try {
|
|
252
|
+
if (typeof root.setState !== 'function') {
|
|
253
|
+
throw new Error('no setState() method');
|
|
254
|
+
}
|
|
255
|
+
root.setState(patch);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
console.error(`[WebUI] streaming: state update failed for <${root.tagName.toLowerCase()}>: ${streamingErrorMessage(error)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
notifyCommit(`${BOUNDARY_MARK_PREFIX}${target}${UPDATE_MARK_SUFFIX}`, sequence, 'update');
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
fail(`error applying state update to boundary ${target}: ${streamingErrorMessage(error)}`);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
removeBoundaryScaffolding(sentinel, scriptEl, null, null);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function commitTerminal(sequence, sentinel, scriptEl) {
|
|
271
|
+
markBoundaryPending();
|
|
272
|
+
removeBoundaryScaffolding(sentinel, scriptEl, null, null);
|
|
273
|
+
terminalCommitted = true;
|
|
274
|
+
pendingTerminalSequence = sequence;
|
|
275
|
+
clearUpdatableBoundaries();
|
|
276
|
+
scheduleTerminalValidation();
|
|
277
|
+
}
|
|
278
|
+
function clearUpdatableBoundaries() {
|
|
279
|
+
updatableBoundaries.clear();
|
|
280
|
+
retainedUpdateRoots = 0;
|
|
281
|
+
}
|
|
282
|
+
function notifyCommit(mark, sequence, kind) {
|
|
283
|
+
perf?.mark(mark);
|
|
284
|
+
if (window
|
|
285
|
+
.__WEBUI_STREAMING_DEBUG__ !== true) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
window.dispatchEvent(new CustomEvent(BOUNDARY_HYDRATED_EVENT, {
|
|
289
|
+
detail: { sequence, terminal: kind === 'terminal', kind },
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
function scheduleTerminalValidation() {
|
|
293
|
+
if (pendingTerminalSequence === null ||
|
|
294
|
+
terminalValidationScheduled ||
|
|
295
|
+
slicedDrainActive) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
terminalValidationScheduled = true;
|
|
299
|
+
const generation = coordinatorGeneration;
|
|
300
|
+
queueMicrotask(() => validatePendingTerminal(generation));
|
|
301
|
+
}
|
|
302
|
+
function validatePendingTerminal(generation) {
|
|
303
|
+
if (generation !== coordinatorGeneration)
|
|
304
|
+
return;
|
|
305
|
+
terminalValidationScheduled = false;
|
|
306
|
+
if (pendingTerminalSequence === null)
|
|
307
|
+
return;
|
|
308
|
+
if (pumpScheduled || queueHead < queue.length) {
|
|
309
|
+
scheduleTerminalValidation();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
settlePendingTerminal(!halted);
|
|
313
|
+
}
|
|
314
|
+
function settlePendingTerminal(success) {
|
|
315
|
+
const sequence = pendingTerminalSequence;
|
|
316
|
+
if (sequence === null)
|
|
317
|
+
return;
|
|
318
|
+
pendingTerminalSequence = null;
|
|
319
|
+
if (success)
|
|
320
|
+
notifyCommit(TERMINAL_MARK, sequence, 'terminal');
|
|
321
|
+
markBoundaryCommitted(success);
|
|
322
|
+
}
|
|
323
|
+
export function installStreamingTruncationGuard() {
|
|
324
|
+
if (typeof document === 'undefined')
|
|
325
|
+
return;
|
|
326
|
+
const generation = coordinatorGeneration;
|
|
327
|
+
if (document.readyState === 'loading') {
|
|
328
|
+
document.addEventListener('DOMContentLoaded', () => onDomContentLoaded(generation), { once: true });
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
queueMicrotask(() => onDomContentLoaded(generation));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function onDomContentLoaded(generation) {
|
|
335
|
+
if (generation !== coordinatorGeneration)
|
|
336
|
+
return;
|
|
337
|
+
if (halted) {
|
|
338
|
+
abandonDeferredDocumentRoots();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (terminalCommitted)
|
|
342
|
+
return;
|
|
343
|
+
fail('response ended at DOMContentLoaded before the terminal streaming boundary committed (truncated stream)');
|
|
344
|
+
}
|
|
345
|
+
export function resetStreamingCoordinatorStateForTests() {
|
|
346
|
+
resetDeferredActivationForTests();
|
|
347
|
+
settlePendingTerminal(false);
|
|
348
|
+
clearUpdatableBoundaries();
|
|
349
|
+
coordinatorGeneration++;
|
|
350
|
+
queue.length = 0;
|
|
351
|
+
queueHead = 0;
|
|
352
|
+
pumpScheduled = false;
|
|
353
|
+
slicedDrainActive = false;
|
|
354
|
+
halted = false;
|
|
355
|
+
nextExpectedRecordSequence = 0;
|
|
356
|
+
nextExpectedBoundaryId = 0;
|
|
357
|
+
terminalCommitted = false;
|
|
358
|
+
pendingTerminalSequence = null;
|
|
359
|
+
terminalValidationScheduled = false;
|
|
360
|
+
}
|
|
361
|
+
export function isStreamingHaltedForTests() {
|
|
362
|
+
return halted;
|
|
363
|
+
}
|
|
364
|
+
export function streamingRetentionStateForTests() {
|
|
365
|
+
return [updatableBoundaries.size, retainedUpdateRoots];
|
|
366
|
+
}
|
|
367
|
+
export { elementHasPendingStateForTests, pendingTagWaiterCountForTests, pendingUndefinedRootCountForTests, };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const ACTIVATION_MISSING_TEMPLATE = 3;
|
|
2
|
+
export declare const ELEMENT_IGNORED = 0;
|
|
3
|
+
export declare const ELEMENT_DEFERRED = 4;
|
|
4
|
+
export declare const ELEMENT_LIMIT_FAILURE = 5;
|
|
5
|
+
export declare const MAX_PENDING_UNDEFINED_ROOTS = 50000;
|
|
6
|
+
export interface PendingBoundaryUpdates {
|
|
7
|
+
readonly roots: Element[];
|
|
8
|
+
pendingRoots: number;
|
|
9
|
+
patch?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface DeferredActivationOptions {
|
|
12
|
+
updates?: PendingBoundaryUpdates;
|
|
13
|
+
retainedRoots?: Element[];
|
|
14
|
+
}
|
|
15
|
+
export declare function configureStreamingFailureHandler(handler: (reason: string) => void): void;
|
|
16
|
+
export declare function activateElement(el: Element, state: Record<string, unknown> | undefined, updates?: PendingBoundaryUpdates): number;
|
|
17
|
+
export declare function activateDeferredTree(first: Node | null, root: Node, end: Node | null, state: Record<string, unknown> | undefined, options?: DeferredActivationOptions): string | null;
|
|
18
|
+
export declare function abandonPendingWaiters(): void;
|
|
19
|
+
export declare function resetDeferredActivationForTests(): void;
|
|
20
|
+
export declare function pendingTagWaiterCountForTests(): number;
|
|
21
|
+
export declare function pendingUndefinedRootCountForTests(): number;
|
|
22
|
+
export declare function elementHasPendingStateForTests(el: Element): boolean;
|