@govela/runtime 0.0.0-stage → 0.3.0

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.
@@ -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
+ }