@govela/runtime 0.0.0-stage → 0.3.1
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/LICENSE +21 -0
- package/README.md +17 -2
- package/package.json +19 -4
- package/runtime-blocks.js +875 -0
- package/runtime-hydrate.js +415 -0
- package/runtime.js +562 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vela block runtime (docs/HYDRATION_ARCHITECTURE.md).
|
|
3
|
+
*
|
|
4
|
+
* Structural template behaviour lives here once instead of being generated
|
|
5
|
+
* into every component: compiled fragments build DOM through a cursor, and
|
|
6
|
+
* blocks ($$if, $$each, $$key, $$await, $$component, $$slot, $$client) own
|
|
7
|
+
* their DOM ranges and child resources.
|
|
8
|
+
*
|
|
9
|
+
* Fragment contract (F-2.x): a fragment is `(cursor, scope) => handle`, where
|
|
10
|
+
* handle is `{ p(dirty, scopeChanged) }` or undefined. The runtime never
|
|
11
|
+
* interprets `dirty`; blocks receive an optional `changed(dirty)` predicate
|
|
12
|
+
* generated by the compiler and forward `dirty` to their fragments.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { $$runtimeContract1 } from './runtime.js';
|
|
16
|
+
void $$runtimeContract1;
|
|
17
|
+
|
|
18
|
+
// ── Owners ───────────────────────────────────────────────────────────────
|
|
19
|
+
// An owner collects the resources created while a fragment mounts
|
|
20
|
+
// (listeners, child blocks, component instances, deferred work) and releases
|
|
21
|
+
// them in reverse registration order (B-3.2, B-3.3). Releasing never touches
|
|
22
|
+
// the DOM: the block that owns a range removes it as a whole.
|
|
23
|
+
|
|
24
|
+
// stats counts live owners and listeners for the destroy checks of the
|
|
25
|
+
// test harness (Q-10.2 item 5, task 052).
|
|
26
|
+
const stats = { owners: 0, listeners: 0 };
|
|
27
|
+
|
|
28
|
+
// $$debugStats reports live owners, live listeners, and queued updates.
|
|
29
|
+
export function $$debugStats() {
|
|
30
|
+
const batch = globalThis[Symbol.for('vela.batch')];
|
|
31
|
+
return { owners: stats.owners, listeners: stats.listeners, queued: batch ? batch.queue.length : 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// $$listen adds an event listener that the owner removes.
|
|
35
|
+
export function $$listen(owner, target, event, handler, options) {
|
|
36
|
+
target.addEventListener(event, handler, options);
|
|
37
|
+
stats.listeners++;
|
|
38
|
+
owner.add(() => {
|
|
39
|
+
target.removeEventListener(event, handler, options);
|
|
40
|
+
stats.listeners--;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function $$owner(parent) {
|
|
45
|
+
stats.owners++;
|
|
46
|
+
const owner = {
|
|
47
|
+
done: false,
|
|
48
|
+
cleanups: [],
|
|
49
|
+
add(cleanup) {
|
|
50
|
+
if (owner.done) {
|
|
51
|
+
cleanup();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
owner.cleanups.push(cleanup);
|
|
55
|
+
},
|
|
56
|
+
release() {
|
|
57
|
+
if (owner.done) return;
|
|
58
|
+
owner.done = true;
|
|
59
|
+
stats.owners--;
|
|
60
|
+
const cleanups = owner.cleanups;
|
|
61
|
+
owner.cleanups = [];
|
|
62
|
+
for (let i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
if (parent) parent.add(() => owner.release());
|
|
66
|
+
return owner;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Ranges ───────────────────────────────────────────────────────────────
|
|
70
|
+
// A range is the DOM a fragment occupies. A single top-level element or text
|
|
71
|
+
// node is its own range; anything else is wrapped in an empty comment pair,
|
|
72
|
+
// so nodes added later by nested blocks stay inside it.
|
|
73
|
+
|
|
74
|
+
function rangeOf(fragment) {
|
|
75
|
+
const nodes = fragment.childNodes;
|
|
76
|
+
if (nodes.length === 1 && (nodes[0].nodeType === 1 || nodes[0].nodeType === 3)) {
|
|
77
|
+
return { start: nodes[0], end: nodes[0] };
|
|
78
|
+
}
|
|
79
|
+
const start = document.createComment('');
|
|
80
|
+
const end = document.createComment('');
|
|
81
|
+
fragment.insertBefore(start, fragment.firstChild);
|
|
82
|
+
fragment.appendChild(end);
|
|
83
|
+
return { start, end };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rangeNodes(range) {
|
|
87
|
+
const nodes = [];
|
|
88
|
+
// An adopted fragment with no nodes has no range (hydration, C-4.3).
|
|
89
|
+
if (!range) return nodes;
|
|
90
|
+
for (let node = range.start; node; node = node.nextSibling) {
|
|
91
|
+
nodes.push(node);
|
|
92
|
+
if (node === range.end) break;
|
|
93
|
+
}
|
|
94
|
+
return nodes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function removeRange(range) {
|
|
98
|
+
for (const node of rangeNodes(range)) node.remove();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function moveRange(range, before) {
|
|
102
|
+
const parent = before.parentNode;
|
|
103
|
+
for (const node of rangeNodes(range)) parent.insertBefore(node, before);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rangeContains(range, node) {
|
|
107
|
+
for (const item of rangeNodes(range)) {
|
|
108
|
+
if (item === node || item.contains(node)) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Create cursor ────────────────────────────────────────────────────────
|
|
114
|
+
// The create cursor builds new DOM (C-4.1). The hydrate cursor of task 041
|
|
115
|
+
// implements the same interface over existing server DOM.
|
|
116
|
+
|
|
117
|
+
export function $$template(html) {
|
|
118
|
+
const template = document.createElement('template');
|
|
119
|
+
template.innerHTML = html;
|
|
120
|
+
return template;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createCursor(parent, before, owner) {
|
|
124
|
+
const stack = [];
|
|
125
|
+
const cursor = {
|
|
126
|
+
owner,
|
|
127
|
+
hydrating: false,
|
|
128
|
+
// at names the next adopted site for hydration reports (V-11.3b).
|
|
129
|
+
at() {},
|
|
130
|
+
insert(node) {
|
|
131
|
+
parent.insertBefore(node, before);
|
|
132
|
+
return node;
|
|
133
|
+
},
|
|
134
|
+
// element creates an element with its static attributes, given as a flat
|
|
135
|
+
// [name, value, ...] list (the scope attribute comes first).
|
|
136
|
+
element(tag, attrs) {
|
|
137
|
+
const element = document.createElement(tag);
|
|
138
|
+
if (attrs) for (let i = 0; i < attrs.length; i += 2) element.setAttribute(attrs[i], attrs[i + 1]);
|
|
139
|
+
return cursor.insert(element);
|
|
140
|
+
},
|
|
141
|
+
text(value) {
|
|
142
|
+
return cursor.insert(document.createTextNode(value));
|
|
143
|
+
},
|
|
144
|
+
comment() {
|
|
145
|
+
return cursor.insert(document.createComment(''));
|
|
146
|
+
},
|
|
147
|
+
static(template) {
|
|
148
|
+
const content = template.content.cloneNode(true);
|
|
149
|
+
const nodes = [...content.childNodes];
|
|
150
|
+
cursor.insert(content);
|
|
151
|
+
return nodes;
|
|
152
|
+
},
|
|
153
|
+
enter(element) {
|
|
154
|
+
stack.push([parent, before]);
|
|
155
|
+
parent = element;
|
|
156
|
+
before = null;
|
|
157
|
+
},
|
|
158
|
+
exit() {
|
|
159
|
+
[parent, before] = stack.pop();
|
|
160
|
+
},
|
|
161
|
+
// init runs an initial DOM write (attribute, property, class, style,
|
|
162
|
+
// control value). The hydrate cursor adopts the server value instead
|
|
163
|
+
// (F-2.2), so writes go through this hook rather than running inline.
|
|
164
|
+
init(write) {
|
|
165
|
+
write();
|
|
166
|
+
},
|
|
167
|
+
// adopt takes a live control value on hydration (C-4.5); creating has
|
|
168
|
+
// nothing to adopt.
|
|
169
|
+
adopt() {},
|
|
170
|
+
listen(target, event, handler, options) {
|
|
171
|
+
$$listen(owner, target, event, handler, options);
|
|
172
|
+
},
|
|
173
|
+
block(kind, ...args) {
|
|
174
|
+
return kind(cursor, ...args);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
return cursor;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// mountFragment builds a fragment detached from the document, computes its
|
|
181
|
+
// range, then inserts it before `before`. The owning block releases the
|
|
182
|
+
// fragment, so its owner is not registered with a parent: a parent cleanup
|
|
183
|
+
// per mounted branch or row would accumulate across updates.
|
|
184
|
+
function mountFragment(fragmentFn, scope, before) {
|
|
185
|
+
const owner = $$owner(null);
|
|
186
|
+
const container = document.createDocumentFragment();
|
|
187
|
+
const handle = fragmentFn(createCursor(container, null, owner), scope);
|
|
188
|
+
const range = rangeOf(container);
|
|
189
|
+
before.parentNode.insertBefore(container, before);
|
|
190
|
+
return { owner, handle, range, scope };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function updateMounted(mounted, dirty, scopeChanged) {
|
|
194
|
+
if (mounted && mounted.handle && mounted.handle.p) mounted.handle.p(dirty, scopeChanged);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function unmount(mounted) {
|
|
198
|
+
if (!mounted) return;
|
|
199
|
+
mounted.owner.release();
|
|
200
|
+
removeRange(mounted.range);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// registerBlock ties a block to its owner and gives it the common
|
|
204
|
+
// destroy/abandon behaviour (B-3.3, B-3.4).
|
|
205
|
+
function registerBlock(cursor, block, anchor, release) {
|
|
206
|
+
let done = false;
|
|
207
|
+
const finish = (removeDom) => {
|
|
208
|
+
if (done) return;
|
|
209
|
+
done = true;
|
|
210
|
+
release(removeDom);
|
|
211
|
+
if (removeDom) anchor.remove();
|
|
212
|
+
};
|
|
213
|
+
block.destroy = () => finish(true);
|
|
214
|
+
block.abandon = () => finish(false);
|
|
215
|
+
block.isDone = () => done;
|
|
216
|
+
cursor.owner.add(() => finish(false));
|
|
217
|
+
return block;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Root ─────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
// $$mountRoot mounts a component's root fragment into target (an element,
|
|
223
|
+
// or a comment anchor that the content is inserted before).
|
|
224
|
+
export function $$mountRoot(target, fragmentFn, scope) {
|
|
225
|
+
const owner = $$owner(null);
|
|
226
|
+
const before = target.nodeType === 8 ? target : null;
|
|
227
|
+
const parent = before ? before.parentNode : target;
|
|
228
|
+
const handle = fragmentFn(createCursor(parent, before, owner), scope);
|
|
229
|
+
return {
|
|
230
|
+
owner,
|
|
231
|
+
p(dirty) {
|
|
232
|
+
if (handle && handle.p) handle.p(dirty, false);
|
|
233
|
+
},
|
|
234
|
+
release() {
|
|
235
|
+
owner.release();
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
// ── Hydration session ────────────────────────────────────────────────────
|
|
242
|
+
// While hydrate() (runtime-hydrate.js) adopts a root, the session holds the
|
|
243
|
+
// payload stream and the hydrate cursor at the position where the next
|
|
244
|
+
// component factory adopts its DOM. Every mount after the root commits
|
|
245
|
+
// creates DOM (C-4.4).
|
|
246
|
+
|
|
247
|
+
let $$session = null;
|
|
248
|
+
|
|
249
|
+
// $$setHydration installs or clears the session (runtime-hydrate.js).
|
|
250
|
+
export function $$setHydration(session) {
|
|
251
|
+
const previous = $$session;
|
|
252
|
+
$$session = session;
|
|
253
|
+
return previous;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// $$hydrationState returns the restored state of the component instance
|
|
257
|
+
// that starts now: its "s" entry without omitted bindings (S-6.8), or null
|
|
258
|
+
// when the component is created rather than hydrated.
|
|
259
|
+
export function $$hydrationState(component) {
|
|
260
|
+
if (!$$session || !$$session.cursors[$$session.cursors.length - 1]) return null;
|
|
261
|
+
return $$session.state(component);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// withComponentCursor makes cursor the position the next component factory
|
|
265
|
+
// hydrates at.
|
|
266
|
+
function withComponentCursor(cursor, mount) {
|
|
267
|
+
$$session.cursors.push(cursor);
|
|
268
|
+
try {
|
|
269
|
+
return mount();
|
|
270
|
+
} finally {
|
|
271
|
+
$$session.cursors.pop();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// $$mountComponent mounts a component's root fragment. Created: into a new
|
|
276
|
+
// display:contents wrapper (when wrapperScope is not null; "" is a wrapper
|
|
277
|
+
// without a scope attribute, for a component without styles) or a detached
|
|
278
|
+
// fragment, then inserted at target. Hydrated: on the server DOM at the
|
|
279
|
+
// cursor of the enclosing block or root. setRoot receives the wrapper;
|
|
280
|
+
// getRoot returns the component element afterwards.
|
|
281
|
+
export function $$mountComponent(target, fragmentFn, wrapperScope, setRoot, getRoot) {
|
|
282
|
+
const outer = $$session ? $$session.cursors[$$session.cursors.length - 1] || null : null;
|
|
283
|
+
if (outer) {
|
|
284
|
+
const owner = $$owner(null);
|
|
285
|
+
let handle;
|
|
286
|
+
if (wrapperScope != null) {
|
|
287
|
+
const wrapper = outer.element('div', wrapperScope ? [wrapperScope, '', 'style', 'display: contents;'] : ['style', 'display: contents;']);
|
|
288
|
+
setRoot(wrapper);
|
|
289
|
+
outer.enter(wrapper);
|
|
290
|
+
handle = fragmentFn(outer.sub(owner));
|
|
291
|
+
outer.exit();
|
|
292
|
+
} else {
|
|
293
|
+
handle = fragmentFn(outer.sub(owner));
|
|
294
|
+
}
|
|
295
|
+
return rootView(owner, handle);
|
|
296
|
+
}
|
|
297
|
+
let view;
|
|
298
|
+
if (wrapperScope != null) {
|
|
299
|
+
const wrapper = document.createElement('div');
|
|
300
|
+
if (wrapperScope) wrapper.setAttribute(wrapperScope, '');
|
|
301
|
+
wrapper.style.display = 'contents';
|
|
302
|
+
setRoot(wrapper);
|
|
303
|
+
view = $$mountRoot(wrapper, fragmentFn);
|
|
304
|
+
} else {
|
|
305
|
+
view = $$mountRoot(document.createDocumentFragment(), fragmentFn);
|
|
306
|
+
}
|
|
307
|
+
const root = getRoot();
|
|
308
|
+
if (target.nodeType === 8) target.parentNode.insertBefore(root, target);
|
|
309
|
+
else target.appendChild(root);
|
|
310
|
+
return view;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function rootView(owner, handle) {
|
|
314
|
+
return {
|
|
315
|
+
owner,
|
|
316
|
+
p(dirty) {
|
|
317
|
+
if (handle && handle.p) handle.p(dirty, false);
|
|
318
|
+
},
|
|
319
|
+
release() {
|
|
320
|
+
owner.release();
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// ── $$if ─────────────────────────────────────────────────────────────────
|
|
325
|
+
// select() returns the active branch index or -1.
|
|
326
|
+
|
|
327
|
+
export function $$if(cursor, select, branches, changed) {
|
|
328
|
+
let anchor;
|
|
329
|
+
let index = -1;
|
|
330
|
+
let current = null;
|
|
331
|
+
const show = (next) => {
|
|
332
|
+
index = next;
|
|
333
|
+
current = next >= 0 ? mountFragment(branches[next], undefined, anchor) : null;
|
|
334
|
+
};
|
|
335
|
+
if (cursor.hydrating) {
|
|
336
|
+
// The range state is the branch the server rendered (X-7.1).
|
|
337
|
+
const token = cursor.open();
|
|
338
|
+
index = select();
|
|
339
|
+
cursor.expect(token === (index >= 0 ? String(index) : 'x'), `#if renders branch ${index >= 0 ? index : 'x'}, the server rendered ${token}`);
|
|
340
|
+
if (index >= 0) current = cursor.mount(branches[index]);
|
|
341
|
+
anchor = cursor.close();
|
|
342
|
+
} else {
|
|
343
|
+
anchor = cursor.comment();
|
|
344
|
+
show(select());
|
|
345
|
+
}
|
|
346
|
+
const block = {
|
|
347
|
+
p(dirty) {
|
|
348
|
+
if (block.isDone()) return;
|
|
349
|
+
if (!changed || changed(dirty)) {
|
|
350
|
+
const next = select();
|
|
351
|
+
if (next !== index) {
|
|
352
|
+
unmount(current);
|
|
353
|
+
show(next);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
updateMounted(current, dirty, false);
|
|
358
|
+
},
|
|
359
|
+
move(before) {
|
|
360
|
+
if (current) moveRange(current.range, before);
|
|
361
|
+
before.parentNode.insertBefore(anchor, before);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
return registerBlock(cursor, block, anchor, (removeDom) => {
|
|
365
|
+
if (!current) return;
|
|
366
|
+
if (removeDom) unmount(current);
|
|
367
|
+
else current.owner.release();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── $$key ────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
export function $$key(cursor, value, body, changed) {
|
|
374
|
+
let anchor;
|
|
375
|
+
let key = value();
|
|
376
|
+
let current;
|
|
377
|
+
if (cursor.hydrating) {
|
|
378
|
+
cursor.open();
|
|
379
|
+
current = cursor.mount(body);
|
|
380
|
+
anchor = cursor.close();
|
|
381
|
+
} else {
|
|
382
|
+
anchor = cursor.comment();
|
|
383
|
+
current = mountFragment(body, undefined, anchor);
|
|
384
|
+
}
|
|
385
|
+
const block = {
|
|
386
|
+
p(dirty) {
|
|
387
|
+
if (block.isDone()) return;
|
|
388
|
+
if (!changed || changed(dirty)) {
|
|
389
|
+
const next = value();
|
|
390
|
+
if (next !== key) {
|
|
391
|
+
key = next;
|
|
392
|
+
unmount(current);
|
|
393
|
+
current = mountFragment(body, undefined, anchor);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
updateMounted(current, dirty, false);
|
|
398
|
+
},
|
|
399
|
+
move(before) {
|
|
400
|
+
moveRange(current.range, before);
|
|
401
|
+
before.parentNode.insertBefore(anchor, before);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
return registerBlock(cursor, block, anchor, (removeDom) => {
|
|
405
|
+
if (removeDom) unmount(current);
|
|
406
|
+
else current.owner.release();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── $$each ───────────────────────────────────────────────────────────────
|
|
411
|
+
// collection() returns the iterable; key(item, index) returns the row key or
|
|
412
|
+
// is null for an unkeyed list. A row fragment receives the scope
|
|
413
|
+
// { item, index } and is updated in place with scopeChanged = true.
|
|
414
|
+
// dirtyRows(), when given, returns the indexes of rows changed only by
|
|
415
|
+
// in-place mutation of a deep collection (or null): those rows update
|
|
416
|
+
// without a reconciliation, as long as the row count is unchanged.
|
|
417
|
+
|
|
418
|
+
export function $$each(cursor, collection, key, row, fallback, changed, dirtyRows) {
|
|
419
|
+
let anchor;
|
|
420
|
+
let rows = [];
|
|
421
|
+
let byKey = key ? new Map() : null;
|
|
422
|
+
let empty = null;
|
|
423
|
+
|
|
424
|
+
const mountRow = (item, index, before) => mountFragment(row, { item, index }, before);
|
|
425
|
+
|
|
426
|
+
const reconcile = (dirty, initial) => {
|
|
427
|
+
const items = Array.from(collection() || []);
|
|
428
|
+
if (items.length === 0) {
|
|
429
|
+
for (const mounted of rows) unmount(mounted);
|
|
430
|
+
rows = [];
|
|
431
|
+
if (byKey) byKey.clear();
|
|
432
|
+
if (fallback && !empty) empty = mountFragment(fallback, undefined, anchor);
|
|
433
|
+
else updateMounted(empty, dirty, false);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (empty) {
|
|
437
|
+
unmount(empty);
|
|
438
|
+
empty = null;
|
|
439
|
+
}
|
|
440
|
+
if (!byKey) {
|
|
441
|
+
// Unkeyed: rows are positional and update in place.
|
|
442
|
+
for (let i = 0; i < items.length; i++) {
|
|
443
|
+
if (i < rows.length) {
|
|
444
|
+
rows[i].scope.item = items[i];
|
|
445
|
+
rows[i].scope.index = i;
|
|
446
|
+
updateMounted(rows[i], dirty, true);
|
|
447
|
+
} else {
|
|
448
|
+
rows.push(mountRow(items[i], i, anchor));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const mounted of rows.splice(items.length)) unmount(mounted);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// Keyed: retain, move, create and remove rows by key; keep focus inside a
|
|
455
|
+
// retained row that moves.
|
|
456
|
+
const focused = initial ? null : document.activeElement;
|
|
457
|
+
const restoreFocus = focused && focused !== document.body &&
|
|
458
|
+
rows.some((mounted) => rangeContains(mounted.range, focused));
|
|
459
|
+
const next = [];
|
|
460
|
+
const nextKeys = new Map();
|
|
461
|
+
for (let i = 0; i < items.length; i++) {
|
|
462
|
+
const rowKey = key(items[i], i);
|
|
463
|
+
nextKeys.set(rowKey, i);
|
|
464
|
+
let mounted = byKey.get(rowKey);
|
|
465
|
+
if (mounted) {
|
|
466
|
+
mounted.scope.item = items[i];
|
|
467
|
+
mounted.scope.index = i;
|
|
468
|
+
updateMounted(mounted, dirty, true);
|
|
469
|
+
} else {
|
|
470
|
+
mounted = mountRow(items[i], i, anchor);
|
|
471
|
+
byKey.set(rowKey, mounted);
|
|
472
|
+
}
|
|
473
|
+
mounted.key = rowKey;
|
|
474
|
+
next.push(mounted);
|
|
475
|
+
}
|
|
476
|
+
for (const [rowKey, mounted] of byKey) {
|
|
477
|
+
if (!nextKeys.has(rowKey)) {
|
|
478
|
+
unmount(mounted);
|
|
479
|
+
byKey.delete(rowKey);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Place rows in order, moving only those not already in position.
|
|
483
|
+
let before = anchor;
|
|
484
|
+
for (let i = next.length - 1; i >= 0; i--) {
|
|
485
|
+
const mounted = next[i];
|
|
486
|
+
if (mounted.range.end.nextSibling !== before) moveRange(mounted.range, before);
|
|
487
|
+
before = mounted.range.start;
|
|
488
|
+
}
|
|
489
|
+
rows = next;
|
|
490
|
+
if (restoreFocus && focused.isConnected && typeof focused.focus === 'function' &&
|
|
491
|
+
document.activeElement !== focused) {
|
|
492
|
+
focused.focus();
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
if (cursor.hydrating) {
|
|
497
|
+
// Rows are adopted in order; keys come from the restored collection
|
|
498
|
+
// (S-6.10). A different row count leaves a row marker or the end
|
|
499
|
+
// marker unconsumed, which the cursor reports (X-7.1).
|
|
500
|
+
const token = cursor.open();
|
|
501
|
+
const items = Array.from(collection() || []);
|
|
502
|
+
if (items.length === 0) {
|
|
503
|
+
cursor.expect(token === (fallback ? 'e' : ''), `#each is empty, the server rendered ${token === 'e' ? 'the fallback' : 'rows'}`);
|
|
504
|
+
if (fallback) empty = cursor.mount(fallback);
|
|
505
|
+
} else {
|
|
506
|
+
cursor.expect(token === '', `#each has ${items.length} rows, the server rendered the fallback`);
|
|
507
|
+
for (let i = 0; i < items.length; i++) {
|
|
508
|
+
const mounted = cursor.mountRow(row, { item: items[i], index: i });
|
|
509
|
+
if (byKey) {
|
|
510
|
+
mounted.key = key(items[i], i);
|
|
511
|
+
byKey.set(mounted.key, mounted);
|
|
512
|
+
}
|
|
513
|
+
rows.push(mounted);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
anchor = cursor.close();
|
|
517
|
+
} else {
|
|
518
|
+
anchor = cursor.comment();
|
|
519
|
+
reconcile(undefined, true);
|
|
520
|
+
}
|
|
521
|
+
const block = {
|
|
522
|
+
p(dirty) {
|
|
523
|
+
if (block.isDone()) return;
|
|
524
|
+
if (!changed || changed(dirty)) {
|
|
525
|
+
const indexes = dirtyRows ? dirtyRows() : null;
|
|
526
|
+
const items = indexes ? Array.from(collection() || []) : null;
|
|
527
|
+
if (indexes && items.length === rows.length) {
|
|
528
|
+
const updated = new Set(indexes);
|
|
529
|
+
for (let i = 0; i < rows.length; i++) {
|
|
530
|
+
if (updated.has(i)) {
|
|
531
|
+
rows[i].scope.item = items[i];
|
|
532
|
+
rows[i].scope.index = i;
|
|
533
|
+
updateMounted(rows[i], dirty, true);
|
|
534
|
+
} else {
|
|
535
|
+
updateMounted(rows[i], dirty, false);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
reconcile(dirty, false);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
for (const mounted of rows) updateMounted(mounted, dirty, false);
|
|
544
|
+
updateMounted(empty, dirty, false);
|
|
545
|
+
},
|
|
546
|
+
move(before) {
|
|
547
|
+
for (const mounted of rows) moveRange(mounted.range, before);
|
|
548
|
+
if (empty) moveRange(empty.range, before);
|
|
549
|
+
before.parentNode.insertBefore(anchor, before);
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
return registerBlock(cursor, block, anchor, (removeDom) => {
|
|
553
|
+
for (const mounted of rows) {
|
|
554
|
+
if (removeDom) unmount(mounted);
|
|
555
|
+
else mounted.owner.release();
|
|
556
|
+
}
|
|
557
|
+
if (empty) {
|
|
558
|
+
if (removeDom) unmount(empty);
|
|
559
|
+
else empty.owner.release();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── $$await ──────────────────────────────────────────────────────────────
|
|
565
|
+
// Branch fragments receive { value } or { error }. A settlement of a promise
|
|
566
|
+
// that is no longer current is ignored.
|
|
567
|
+
|
|
568
|
+
export function $$await(cursor, operand, pending, then, catcher, changed) {
|
|
569
|
+
let anchor;
|
|
570
|
+
let promise = null;
|
|
571
|
+
let current = null;
|
|
572
|
+
let state = -1;
|
|
573
|
+
|
|
574
|
+
const show = (nextState, scope) => {
|
|
575
|
+
unmount(current);
|
|
576
|
+
current = null;
|
|
577
|
+
state = nextState;
|
|
578
|
+
const fragment = nextState === 0 ? pending : nextState === 1 ? then : catcher;
|
|
579
|
+
if (fragment) current = mountFragment(fragment, scope, anchor);
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// drive evaluates the operand; adopted keeps the adopted pending branch.
|
|
583
|
+
const drive = (adopted) => {
|
|
584
|
+
const next = Promise.resolve(operand());
|
|
585
|
+
if (next === promise) return false;
|
|
586
|
+
promise = next;
|
|
587
|
+
if (!adopted) show(0, undefined);
|
|
588
|
+
next.then(
|
|
589
|
+
(value) => {
|
|
590
|
+
if (promise !== next || block.isDone()) return;
|
|
591
|
+
show(1, { value });
|
|
592
|
+
},
|
|
593
|
+
(error) => {
|
|
594
|
+
if (promise !== next || block.isDone()) return;
|
|
595
|
+
show(2, { error });
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
return true;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const block = {
|
|
602
|
+
p(dirty) {
|
|
603
|
+
if (block.isDone()) return;
|
|
604
|
+
if ((!changed || changed(dirty)) && drive()) return;
|
|
605
|
+
updateMounted(current, dirty, false);
|
|
606
|
+
},
|
|
607
|
+
move(before) {
|
|
608
|
+
if (current) moveRange(current.range, before);
|
|
609
|
+
before.parentNode.insertBefore(anchor, before);
|
|
610
|
+
},
|
|
611
|
+
state: () => state,
|
|
612
|
+
};
|
|
613
|
+
if (cursor.hydrating) {
|
|
614
|
+
// The "a" entry holds the branch the server rendered (S-6.11).
|
|
615
|
+
const token = cursor.open();
|
|
616
|
+
const entry = cursor.entry('a') || {};
|
|
617
|
+
cursor.expect(entry.state === token, `#await entry is ${entry.state}, the server range is ${token}`);
|
|
618
|
+
if (token === 't') {
|
|
619
|
+
state = 1;
|
|
620
|
+
if (then) current = cursor.mount(then, { value: entry.value });
|
|
621
|
+
} else if (token === 'c') {
|
|
622
|
+
state = 2;
|
|
623
|
+
if (catcher) current = cursor.mount(catcher, { error: entry.error });
|
|
624
|
+
} else {
|
|
625
|
+
state = 0;
|
|
626
|
+
if (pending) current = cursor.mount(pending);
|
|
627
|
+
cursor.afterCommit(() => {
|
|
628
|
+
if (!block.isDone()) drive(true);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
anchor = cursor.close();
|
|
632
|
+
} else {
|
|
633
|
+
anchor = cursor.comment();
|
|
634
|
+
}
|
|
635
|
+
registerBlock(cursor, block, anchor, (removeDom) => {
|
|
636
|
+
promise = null;
|
|
637
|
+
if (!current) return;
|
|
638
|
+
if (removeDom) unmount(current);
|
|
639
|
+
else current.owner.release();
|
|
640
|
+
});
|
|
641
|
+
if (!cursor.hydrating) drive();
|
|
642
|
+
return block;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── $$component ──────────────────────────────────────────────────────────
|
|
646
|
+
// Mounts a child through its public factory before an anchor, whichever DOM
|
|
647
|
+
// engine compiled it. props() returns the current prop object; events maps
|
|
648
|
+
// component event names to handlers attached to the child's root element;
|
|
649
|
+
// scope is the parent's CSS scope attribute, set on the child's root.
|
|
650
|
+
|
|
651
|
+
export function $$component(cursor, factory, props, slots, events, changed, scope) {
|
|
652
|
+
if (factory.$$clientOnly && cursor.hydrating) {
|
|
653
|
+
// A client-only component (contract §9.2): the server rendered an empty
|
|
654
|
+
// client range; the component mounts after the root commits (T-9.2b).
|
|
655
|
+
const main = (inner) => {
|
|
656
|
+
const block = $$component(inner, factory, props, slots, events, changed, scope);
|
|
657
|
+
return { p: (dirty) => block.p(dirty) };
|
|
658
|
+
};
|
|
659
|
+
return $$client(cursor, main, null);
|
|
660
|
+
}
|
|
661
|
+
let anchor;
|
|
662
|
+
let instance;
|
|
663
|
+
if (cursor.hydrating) {
|
|
664
|
+
// The child factory adopts the server DOM inside the component range.
|
|
665
|
+
cursor.open();
|
|
666
|
+
instance = withComponentCursor(cursor, () => factory(null, props(), slots || {}));
|
|
667
|
+
anchor = cursor.close();
|
|
668
|
+
} else {
|
|
669
|
+
anchor = cursor.comment();
|
|
670
|
+
// A block created while a root hydrates (a repaired range) creates its
|
|
671
|
+
// children too.
|
|
672
|
+
instance = $$session
|
|
673
|
+
? withComponentCursor(null, () => factory(anchor, props(), slots || {}))
|
|
674
|
+
: factory(anchor, props(), slots || {});
|
|
675
|
+
}
|
|
676
|
+
if (scope && instance.el) cursor.init(() => instance.el.setAttribute(scope, ''));
|
|
677
|
+
if (events) {
|
|
678
|
+
for (const name of Object.keys(events)) cursor.listen(instance.el, name, events[name]);
|
|
679
|
+
}
|
|
680
|
+
const block = {
|
|
681
|
+
instance,
|
|
682
|
+
p(dirty) {
|
|
683
|
+
if (block.isDone()) return;
|
|
684
|
+
// A child without settable state exposes no set().
|
|
685
|
+
if ((!changed || changed(dirty)) && instance.set) instance.set(props());
|
|
686
|
+
},
|
|
687
|
+
move(before) {
|
|
688
|
+
before.parentNode.insertBefore(instance.el, before);
|
|
689
|
+
before.parentNode.insertBefore(anchor, before);
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
// A release without DOM removal (abandon, B-3.4) keeps the child's DOM.
|
|
693
|
+
return registerBlock(cursor, block, anchor, (removeDom) => {
|
|
694
|
+
if (removeDom || !instance.abandon) instance.destroy();
|
|
695
|
+
else instance.abandon();
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ── Slots ────────────────────────────────────────────────────────────────
|
|
700
|
+
// Slots use the component protocol: a slot is a function returning a
|
|
701
|
+
// DocumentFragment with the slot content. The parent
|
|
702
|
+
// owns the content: it is built on the first call, updated by the parent,
|
|
703
|
+
// and the same nodes are returned when the child opens the slot again.
|
|
704
|
+
|
|
705
|
+
// $$slotFactory builds slot content from a parent fragment. The content is
|
|
706
|
+
// owned by the parent's current owner and updated through `live`, a set the
|
|
707
|
+
// parent fragment walks on every update (B-3.7).
|
|
708
|
+
export function $$slotFactory(cursor, live, fragment) {
|
|
709
|
+
let range = null;
|
|
710
|
+
let nodes = null;
|
|
711
|
+
const open = () => {
|
|
712
|
+
const container = document.createDocumentFragment();
|
|
713
|
+
if (range) {
|
|
714
|
+
// Reopen: move the content back. If a child removed the nodes one by
|
|
715
|
+
// one, the range chain is broken; fall back to the last snapshot.
|
|
716
|
+
let current = rangeNodes(range);
|
|
717
|
+
if (current[current.length - 1] !== range.end) current = nodes;
|
|
718
|
+
for (const node of current) container.appendChild(node);
|
|
719
|
+
nodes = current;
|
|
720
|
+
return container;
|
|
721
|
+
}
|
|
722
|
+
const owner = $$owner(cursor.owner);
|
|
723
|
+
const handle = fragment(createCursor(container, null, owner));
|
|
724
|
+
if (handle && handle.p) {
|
|
725
|
+
live.add(handle);
|
|
726
|
+
owner.add(() => live.delete(handle));
|
|
727
|
+
}
|
|
728
|
+
const start = document.createComment('');
|
|
729
|
+
const end = document.createComment('');
|
|
730
|
+
container.insertBefore(start, container.firstChild);
|
|
731
|
+
container.appendChild(end);
|
|
732
|
+
range = { start, end };
|
|
733
|
+
nodes = [...container.childNodes];
|
|
734
|
+
return container;
|
|
735
|
+
};
|
|
736
|
+
// adopt hydrates the content where the server rendered it, inside the
|
|
737
|
+
// child's slot range; reopening returns the adopted nodes.
|
|
738
|
+
open.adopt = (target) => {
|
|
739
|
+
const owner = $$owner(cursor.owner);
|
|
740
|
+
const mounted = target.mount(fragment, undefined, owner);
|
|
741
|
+
if (mounted.handle && mounted.handle.p) {
|
|
742
|
+
live.add(mounted.handle);
|
|
743
|
+
owner.add(() => live.delete(mounted.handle));
|
|
744
|
+
}
|
|
745
|
+
range = mounted.range || { start: null, end: null };
|
|
746
|
+
nodes = rangeNodes(mounted.range);
|
|
747
|
+
return mounted.range;
|
|
748
|
+
};
|
|
749
|
+
return open;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// $$slot opens slot content provided by the parent (from either engine) or
|
|
753
|
+
// mounts the child's own fallback. On release the parent's content is parked
|
|
754
|
+
// in a detached fragment rather than destroyed, so the parent can reopen it.
|
|
755
|
+
export function $$slot(cursor, factory, fallback) {
|
|
756
|
+
let anchor;
|
|
757
|
+
let first = null;
|
|
758
|
+
let last = null;
|
|
759
|
+
let current = null;
|
|
760
|
+
if (cursor.hydrating) {
|
|
761
|
+
cursor.open();
|
|
762
|
+
if (factory) {
|
|
763
|
+
cursor.expect(typeof factory.adopt === 'function', 'slot content without an adopt() method cannot be hydrated');
|
|
764
|
+
const adopted = factory.adopt ? factory.adopt(cursor) : null;
|
|
765
|
+
if (adopted) {
|
|
766
|
+
first = adopted.start;
|
|
767
|
+
last = adopted.end;
|
|
768
|
+
}
|
|
769
|
+
} else if (fallback) {
|
|
770
|
+
current = cursor.mount(fallback);
|
|
771
|
+
}
|
|
772
|
+
anchor = cursor.close();
|
|
773
|
+
} else {
|
|
774
|
+
anchor = cursor.comment();
|
|
775
|
+
const content = factory ? factory() : null;
|
|
776
|
+
if (content && content.firstChild) {
|
|
777
|
+
first = content.firstChild;
|
|
778
|
+
last = content.lastChild;
|
|
779
|
+
anchor.parentNode.insertBefore(content, anchor);
|
|
780
|
+
} else if (fallback) {
|
|
781
|
+
current = mountFragment(fallback, undefined, anchor);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const park = () => {
|
|
785
|
+
if (!first) return;
|
|
786
|
+
const holder = document.createDocumentFragment();
|
|
787
|
+
for (let node = first; node;) {
|
|
788
|
+
const next = node.nextSibling;
|
|
789
|
+
holder.appendChild(node);
|
|
790
|
+
if (node === last) break;
|
|
791
|
+
node = next;
|
|
792
|
+
}
|
|
793
|
+
first = last = null;
|
|
794
|
+
};
|
|
795
|
+
const block = {
|
|
796
|
+
p(dirty) {
|
|
797
|
+
// Parent content is updated by its parent; only the child's fallback
|
|
798
|
+
// depends on the child's state.
|
|
799
|
+
if (!block.isDone()) updateMounted(current, dirty, false);
|
|
800
|
+
},
|
|
801
|
+
move(before) {
|
|
802
|
+
if (first) {
|
|
803
|
+
for (let node = first; node;) {
|
|
804
|
+
const next = node.nextSibling;
|
|
805
|
+
before.parentNode.insertBefore(node, before);
|
|
806
|
+
if (node === last) break;
|
|
807
|
+
node = next;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (current) moveRange(current.range, before);
|
|
811
|
+
before.parentNode.insertBefore(anchor, before);
|
|
812
|
+
},
|
|
813
|
+
};
|
|
814
|
+
return registerBlock(cursor, block, anchor, (removeDom) => {
|
|
815
|
+
park();
|
|
816
|
+
if (!current) return;
|
|
817
|
+
if (removeDom) unmount(current);
|
|
818
|
+
else current.owner.release();
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── $$client ─────────────────────────────────────────────────────────────
|
|
823
|
+
// {#client} and client-only components (contract §9). A create cursor (CSR,
|
|
824
|
+
// or a block created after hydration) mounts the main branch directly and
|
|
825
|
+
// ignores the fallback (T-9.1f). Hydration adopts the server range, whose
|
|
826
|
+
// state token is "f" and whose content is the fallback, and after the root
|
|
827
|
+
// commits replaces the fallback with the main branch (T-9.1d). Destroying
|
|
828
|
+
// the owner, or rolling its hydration back, cancels a pending mount
|
|
829
|
+
// (T-9.1e).
|
|
830
|
+
|
|
831
|
+
export function $$client(cursor, main, fallback) {
|
|
832
|
+
let anchor;
|
|
833
|
+
let current = null;
|
|
834
|
+
let mounted = false;
|
|
835
|
+
const block = {
|
|
836
|
+
p(dirty) {
|
|
837
|
+
if (!block.isDone() && mounted) updateMounted(current, dirty, false);
|
|
838
|
+
},
|
|
839
|
+
move(before) {
|
|
840
|
+
if (current) moveRange(current.range, before);
|
|
841
|
+
before.parentNode.insertBefore(anchor, before);
|
|
842
|
+
},
|
|
843
|
+
state: () => (mounted ? 1 : 0),
|
|
844
|
+
};
|
|
845
|
+
if (cursor.hydrating) {
|
|
846
|
+
const token = cursor.open();
|
|
847
|
+
cursor.expect(token === "f", `#client range is ${token}, expected f`);
|
|
848
|
+
if (fallback) current = cursor.mount(fallback);
|
|
849
|
+
anchor = cursor.close();
|
|
850
|
+
cursor.afterCommit(() => {
|
|
851
|
+
if (block.isDone()) return;
|
|
852
|
+
unmount(current);
|
|
853
|
+
current = mountFragment(main, undefined, anchor);
|
|
854
|
+
mounted = true;
|
|
855
|
+
});
|
|
856
|
+
} else {
|
|
857
|
+
anchor = cursor.comment();
|
|
858
|
+
}
|
|
859
|
+
registerBlock(cursor, block, anchor, (removeDom) => {
|
|
860
|
+
if (!current) return;
|
|
861
|
+
if (removeDom) unmount(current);
|
|
862
|
+
else current.owner.release();
|
|
863
|
+
});
|
|
864
|
+
if (!cursor.hydrating) {
|
|
865
|
+
current = mountFragment(main, undefined, anchor);
|
|
866
|
+
mounted = true;
|
|
867
|
+
}
|
|
868
|
+
return block;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// $$createCursor exposes the create cursor to the hydration runtime, which
|
|
872
|
+
// remounts a block whose server range does not match (X-7.3).
|
|
873
|
+
export function $$createCursor(parent, before, owner) {
|
|
874
|
+
return createCursor(parent, before, owner);
|
|
875
|
+
}
|