@govela/runtime 0.4.7 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@govela/runtime",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Runtime of components compiled by the Vela compiler. Generated from runtime/; do not edit.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/runtime-blocks.js CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Structural template behaviour lives here once instead of being generated
5
5
  * into every component: compiled fragments build DOM through a cursor, and
6
- * blocks ($$if, $$each, $$key, $$await, $$component, $$slot, $$client) own
6
+ * blocks ($$if, $$each, $$key, $$await, $$component, $$dynamic, $$slot,
7
+ * $$client) own
7
8
  * their DOM ranges and child resources.
8
9
  *
9
10
  * Fragment contract (F-2.x): a fragment is `(cursor, scope) => handle`, where
@@ -12,7 +13,7 @@
12
13
  * generated by the compiler and forward `dirty` to their fragments.
13
14
  */
14
15
 
15
- import { $$runtimeContract1 } from './runtime.js';
16
+ import { $$runtimeContract1, warn } from './runtime.js';
16
17
  void $$runtimeContract1;
17
18
 
18
19
  // ── Owners ───────────────────────────────────────────────────────────────
@@ -708,6 +709,146 @@ export function $$component(cursor, factory, props, slots, events, changed, scop
708
709
  });
709
710
  }
710
711
 
712
+ // ── $$dynamic ────────────────────────────────────────────────────────────
713
+ // <vela:component :this={…}>: select() returns the component factory to
714
+ // render, or null, undefined or false for nothing. The block owns a range
715
+ // like $$key; the current child is a $$component inside it. The same
716
+ // factory after an update keeps the child and calls set(); another factory
717
+ // destroys the child with its DOM and mounts the next one with a create
718
+ // cursor (B-3.6). Slot content belongs to the parent and moves to the next
719
+ // child (B-3.7).
720
+ //
721
+ // props() returns every prop the template passes; a child gets only the
722
+ // ones its factory declares ($$props, name → bindable). bindings lists the
723
+ // bind: props for the development warning about a child without that
724
+ // $bindable prop; the binding is inert for that child. where is the source
725
+ // position for errors. The range state is "" with a child and "x" without
726
+ // one; hydration compares the factory's $$id with the "d" payload entry the
727
+ // server wrote (S-6.12).
728
+
729
+ // dynamicValue checks the value of :this: a factory, a lazy value
730
+ // (lazy(), task 071), or null for nothing.
731
+ function dynamicValue(value, where) {
732
+ if (value === null || value === undefined || value === false) return null;
733
+ if (typeof value === 'function' || (typeof value === 'object' && value.$$lazy === true)) return value;
734
+ throw new TypeError(`vela: <vela:component> at ${where}: :this is ${value === '' ? 'an empty string' : typeof value}, not a component`);
735
+ }
736
+
737
+ // factoryOf is the component of a selected value; null for a lazy value
738
+ // that is not loaded yet.
739
+ function factoryOf(value) {
740
+ if (!value) return null;
741
+ if (typeof value === 'function') return value;
742
+ return value.loaded ? value.component : null;
743
+ }
744
+
745
+ function dynamicProps(factory, props) {
746
+ const all = props();
747
+ const declared = factory.$$props;
748
+ if (!declared) return all;
749
+ const out = {};
750
+ for (const name of Object.keys(all)) {
751
+ if (name.startsWith('$$onChange_')) {
752
+ if (declared[name.slice(11)] === 1) out[name] = all[name];
753
+ } else if (Object.prototype.hasOwnProperty.call(declared, name)) {
754
+ out[name] = all[name];
755
+ }
756
+ }
757
+ return out;
758
+ }
759
+
760
+ function warnBindings(factory, bindings, where) {
761
+ const declared = factory.$$props;
762
+ if (!bindings || !declared) return;
763
+ for (const name of bindings) {
764
+ if (declared[name] !== 1) {
765
+ warn(`<vela:component> at ${where}: bind:${name} has no effect on ${factory.$$id || factory.name}, which does not declare export let ${name} = $bindable(…)`);
766
+ }
767
+ }
768
+ }
769
+
770
+ export function $$dynamic(cursor, select, props, slots, events, bindings, changed, scope, where) {
771
+ let anchor;
772
+ // selected is the value of :this, the identity compared after updates; a
773
+ // lazy value keeps its identity while it loads.
774
+ let selected = dynamicValue(select(), where);
775
+ let current = null;
776
+ const fragmentFor = (component) => {
777
+ warnBindings(component, bindings, where);
778
+ return (inner) => {
779
+ const child = inner.block($$component, component, () => dynamicProps(component, props), slots, events, null, scope);
780
+ return { p: (dirty) => child.p(dirty) };
781
+ };
782
+ };
783
+ // show mounts the selected component, or for a lazy value that is not
784
+ // loaded yet starts the load and mounts when it arrives, unless :this
785
+ // changed or the block was destroyed meanwhile (B-3.5).
786
+ const show = () => {
787
+ const factory = factoryOf(selected);
788
+ if (factory) {
789
+ current = mountFragment(fragmentFor(factory), undefined, anchor);
790
+ return;
791
+ }
792
+ if (!selected) return;
793
+ const lazyValue = selected;
794
+ lazyValue.load().then(
795
+ (component) => {
796
+ if (block.isDone() || selected !== lazyValue || current) return;
797
+ current = mountFragment(fragmentFor(component), undefined, anchor);
798
+ },
799
+ (error) => {
800
+ if (block.isDone() || selected !== lazyValue) return;
801
+ warn(`<vela:component> at ${where}: the lazy component ${lazyValue.id || '(unnamed)'} failed to load: ${error && error.message ? error.message : error}`);
802
+ },
803
+ );
804
+ };
805
+ const block = {
806
+ p(dirty) {
807
+ if (block.isDone()) return;
808
+ if (changed && !changed(dirty)) return;
809
+ const next = dynamicValue(select(), where);
810
+ if (next !== selected) {
811
+ unmount(current);
812
+ current = null;
813
+ selected = next;
814
+ show();
815
+ return;
816
+ }
817
+ updateMounted(current, dirty, false);
818
+ },
819
+ move(before) {
820
+ if (current) moveRange(current.range, before);
821
+ before.parentNode.insertBefore(anchor, before);
822
+ },
823
+ };
824
+ if (cursor.hydrating) {
825
+ const token = cursor.open();
826
+ const factory = factoryOf(selected);
827
+ if (selected && !factory) {
828
+ // The server rendered the component; its module is not here yet.
829
+ cursor.expect(false, `lazy-not-loaded: <vela:component> renders the lazy component ${selected.id || '(unnamed)'}, which is not loaded; await preloadHydration(container) before hydrate()`);
830
+ }
831
+ const id = factory ? factory.$$id || factory.name : null;
832
+ cursor.expect(token === (factory ? '' : 'x'),
833
+ factory ? `<vela:component> renders ${id}, the server rendered nothing` : `<vela:component> renders nothing, the server rendered a component`);
834
+ if (factory) {
835
+ const server = cursor.entry('d');
836
+ cursor.expect(server === null || server === id, `<vela:component> renders ${id}, the server rendered ${server}`);
837
+ current = cursor.mount(fragmentFor(factory));
838
+ }
839
+ anchor = cursor.close();
840
+ } else {
841
+ anchor = cursor.comment();
842
+ }
843
+ registerBlock(cursor, block, anchor, (removeDom) => {
844
+ if (!current) return;
845
+ if (removeDom) unmount(current);
846
+ else current.owner.release();
847
+ });
848
+ if (!cursor.hydrating) show();
849
+ return block;
850
+ }
851
+
711
852
  // ── Slots ────────────────────────────────────────────────────────────────
712
853
  // Slots use the component protocol: a slot is a function returning a
713
854
  // DocumentFragment with the slot content. The parent
@@ -14,7 +14,7 @@
14
14
  * with one console.error and no value verification.
15
15
  */
16
16
 
17
- import { $$isDev } from './runtime.js';
17
+ import { $$isDev, $$lazyById, warn } from './runtime.js';
18
18
  import { $$owner, $$setHydration, $$createCursor, $$listen } from './runtime-blocks.js';
19
19
 
20
20
  export const MISMATCH = 'VELA-HYDRATION-MISMATCH';
@@ -348,26 +348,68 @@ function createSession(entries, options, container) {
348
348
  return session;
349
349
  }
350
350
 
351
- // hydrate adopts the root range and payload script that a Go renderer
352
- // wrote into container (A-8.7) and returns the component instance, which
353
- // then behaves exactly like one created by the factory.
354
- //
355
- // options: policy ('repair' | 'fail' | 'off'; production builds use their
356
- // own policy), onMismatch(report) (X-7.5), slots.
357
- export function hydrate(Component, container, options = {}) {
358
- let script = null;
351
+ // payloadOf returns the payload of the root in container without
352
+ // consuming it, or null.
353
+ function payloadOf(container) {
359
354
  for (const node of container.childNodes) {
360
- if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) script = node;
355
+ if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) {
356
+ try {
357
+ return JSON.parse(node.textContent);
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
361
362
  }
362
- if (!script) throw mismatch('the container has no payload script (data-vela-root)');
363
+ return null;
364
+ }
365
+
366
+ // preloadHydration loads the lazy components (lazy(), task 071) that the
367
+ // server rendered in container, named by the "d" entries of its payload,
368
+ // so that hydrate() can adopt them synchronously (S-6.14). A lazy module
369
+ // can register further lazy components when it loads; the entries are
370
+ // read again until nothing new loads. A failed load is reported with warn;
371
+ // hydrate() then reports lazy-not-loaded and repairs.
372
+ export async function preloadHydration(container) {
373
+ const payload = payloadOf(container);
374
+ if (!payload || !Array.isArray(payload.entries)) return;
375
+ const ids = [...new Set(payload.entries.filter((entry) => entry[0] === 'd').map((entry) => entry[2]))];
376
+ const done = new Set();
377
+ for (let progress = true; progress;) {
378
+ progress = false;
379
+ const pending = [];
380
+ for (const id of ids) {
381
+ const value = done.has(id) ? null : $$lazyById(id);
382
+ if (!value) continue;
383
+ done.add(id);
384
+ if (value.loaded) continue;
385
+ progress = true;
386
+ pending.push(value.load().catch((error) => {
387
+ warn(`preloadHydration: the lazy component ${id} failed to load: ${error && error.message ? error.message : error}`);
388
+ }));
389
+ }
390
+ await Promise.all(pending);
391
+ }
392
+ }
393
+
394
+ // readPayload parses a payload script (S-6.9) and checks its version.
395
+ function readPayload(script) {
363
396
  const payload = JSON.parse(script.textContent);
364
- if (payload.v !== 3) throw mismatch(`unsupported payload version ${payload.v}`);
365
- const props = payload.props || {};
397
+ if (payload.v !== 4) throw mismatch(`unsupported payload version ${payload.v}`);
398
+ return payload;
399
+ }
366
400
 
367
- let first = container.firstChild;
368
- while (first && first.nodeType === 3 && first.data.trim() === '') first = first.nextSibling;
369
- const pos = { parent: container, next: first, stack: [], owners: [] };
370
- const session = createSession(payload.entries || [], options, container);
401
+ // adoptRoot runs a hydration session for Component over the root range
402
+ // that starts at first (whitespace before it is skipped) and ends before
403
+ // script, its payload, under parent: a container element for hydrate(), the
404
+ // range a <vela:component> block mounts into for serverComponent().
405
+ // observed is the node whose attribute writes the session verifies. It
406
+ // returns { instance, failure, session } and the two ways to finish:
407
+ // commit() removes the payload and runs the after-commit callbacks;
408
+ // rollback() releases everything adopted without touching the DOM (X-7.4).
409
+ function adoptRoot(Component, parent, first, script, payload, options, observed, target) {
410
+ while (first && first !== script && first.nodeType === 3 && first.data.trim() === '') first = first.nextSibling;
411
+ const pos = { parent, next: first, stack: [], owners: [] };
412
+ const session = createSession(payload.entries || [], options, observed);
371
413
  const owner = $$owner(null);
372
414
  pos.owners.push(owner);
373
415
  const cursor = hydrateCursor(pos, owner, session);
@@ -378,7 +420,7 @@ export function hydrate(Component, container, options = {}) {
378
420
  cursor.open();
379
421
  session.cursors.push(cursor);
380
422
  try {
381
- instance = Component(container, props, options.slots || {});
423
+ instance = Component(target, payload.props || {}, options.slots || {});
382
424
  } finally {
383
425
  session.cursors.pop();
384
426
  }
@@ -391,25 +433,167 @@ export function hydrate(Component, container, options = {}) {
391
433
  $$setHydration(previous);
392
434
  if (session.observer) session.observer.disconnect();
393
435
  }
394
- if (failure) {
395
- // Rollback (X-7.4): release everything adopted without touching the
396
- // DOM; the server DOM and payload stay for a retry.
397
- if (instance && instance.abandon) instance.abandon();
398
- for (let i = pos.owners.length - 1; i >= 0; i--) pos.owners[i].release();
399
- if (!isMismatch(failure) || session.policy === 'fail') throw failure;
436
+ return {
437
+ instance,
438
+ failure,
439
+ session,
440
+ rollback() {
441
+ if (instance && instance.abandon) instance.abandon();
442
+ for (let i = pos.owners.length - 1; i >= 0; i--) pos.owners[i].release();
443
+ },
444
+ commit() {
445
+ script.remove();
446
+ for (const callback of session.after) callback();
447
+ // The root owner holds what the root cursor adopted; it goes with the
448
+ // instance (Q-10.2 item 5).
449
+ const destroy = instance.destroy;
450
+ instance.destroy = function () {
451
+ destroy.call(this);
452
+ owner.release();
453
+ };
454
+ // A parent block releases a child without removing its DOM through
455
+ // abandon (B-3.4), as <vela:component> does when its value changes.
456
+ const abandon = instance.abandon;
457
+ if (abandon) {
458
+ instance.abandon = function () {
459
+ abandon.call(this);
460
+ owner.release();
461
+ };
462
+ }
463
+ return instance;
464
+ },
465
+ };
466
+ }
467
+
468
+ // hydrate adopts the root range and payload script that a Go renderer
469
+ // wrote into container (A-8.7) and returns the component instance, which
470
+ // then behaves exactly like one created by the factory.
471
+ //
472
+ // options: policy ('repair' | 'fail' | 'off'; production builds use their
473
+ // own policy), onMismatch(report) (X-7.5), slots.
474
+ export function hydrate(Component, container, options = {}) {
475
+ let script = null;
476
+ for (const node of container.childNodes) {
477
+ if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) script = node;
478
+ }
479
+ if (!script) throw mismatch('the container has no payload script (data-vela-root)');
480
+ const payload = readPayload(script);
481
+ const props = payload.props || {};
482
+ const result = adoptRoot(Component, container, container.firstChild, script, payload, options, container, container);
483
+ if (result.failure) {
484
+ result.rollback();
485
+ if (!isMismatch(result.failure) || result.session.policy === 'fail') throw result.failure;
400
486
  // A mismatch at the root range remounts the root (X-7.3).
401
- session.report(failure);
487
+ result.session.report(result.failure);
402
488
  while (container.firstChild) container.firstChild.remove();
403
489
  return Component(container, props, options.slots || {});
404
490
  }
405
- script.remove();
406
- for (const callback of session.after) callback();
407
- // The root owner holds what the root cursor adopted; it goes with the
408
- // instance (Q-10.2 item 5).
409
- const destroy = instance.destroy;
410
- instance.destroy = function () {
411
- destroy.call(this);
412
- owner.release();
413
- };
414
- return instance;
491
+ return result.commit();
492
+ }
493
+
494
+ // ── Server-rendered fragments (task 072) ─────────────────────────────────
495
+ // serverComponent and staticHTML turn HTML that arrives after the page
496
+ // loaded into component values for <vela:component> (§8.3). The HTML is
497
+ // parsed with a <template>, so its scripts never run; the payload script is
498
+ // read as JSON only. HTML from the server is trusted like the document:
499
+ // never pass HTML from another origin.
500
+
501
+ function parseHTML(html) {
502
+ const template = document.createElement('template');
503
+ template.innerHTML = html;
504
+ return template.content;
505
+ }
506
+
507
+ // The target of a component factory: a comment anchor the nodes go before
508
+ // (how <vela:component> mounts a child), or an element they are appended to.
509
+ function placeOf(target, what) {
510
+ if (target && target.nodeType === 8 && target.parentNode) return [target.parentNode, target];
511
+ if (target && (target.nodeType === 1 || target.nodeType === 11)) return [target, null];
512
+ throw new TypeError(`vela: ${what} mounts only after the page hydrated, into <vela:component> or an element`);
513
+ }
514
+
515
+ // serverComponent(Component, html, options) is a component value whose
516
+ // first mount adopts html: the root range and payload script that
517
+ // RenderXContext of Component wrote. The nodes are inserted where the value
518
+ // mounts and hydrated like hydrate() does, with the same options (policy,
519
+ // onMismatch). Under repair a mismatch removes them and creates Component
520
+ // with the props of the payload; under fail it throws. Props come from the
521
+ // payload; the props the parent passes are applied with set() after the
522
+ // commit. A later mount of the same value adopts a fresh copy of html.
523
+ export function serverComponent(Component, html, options = {}) {
524
+ if (typeof Component !== 'function') throw new TypeError('vela: serverComponent() needs a component factory');
525
+ if (typeof html !== 'string') throw new TypeError('vela: serverComponent() needs the HTML as a string');
526
+ function Adopted(target, props, slots) {
527
+ const [parent, before] = placeOf(target, 'serverComponent()');
528
+ const content = parseHTML(html);
529
+ let script = null;
530
+ for (const node of content.childNodes) {
531
+ if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) script = node;
532
+ }
533
+ if (!script) throw mismatch('serverComponent(): the HTML has no payload script (data-vela-root)');
534
+ const payload = readPayload(script);
535
+ const nodes = [...content.childNodes];
536
+ parent.insertBefore(content, before);
537
+ const own = Object.assign({}, options, slots && Object.keys(slots).length ? { slots } : {});
538
+ const result = adoptRoot(Component, parent, nodes[0], script, payload, own, parent, target);
539
+ if (result.failure) {
540
+ result.rollback();
541
+ for (const node of nodes) node.remove();
542
+ if (!isMismatch(result.failure) || result.session.policy === 'fail') throw result.failure;
543
+ result.session.report(result.failure);
544
+ const created = Component(target, payload.props || {}, slots || {});
545
+ if (props && created.set && Object.keys(props).length) created.set(props);
546
+ return created;
547
+ }
548
+ const instance = result.commit();
549
+ const destroy = instance.destroy;
550
+ instance.destroy = function () {
551
+ destroy.call(this);
552
+ // The root range markers and whitespace around it.
553
+ for (const node of nodes) if (node.parentNode) node.remove();
554
+ };
555
+ if (props && instance.set && Object.keys(props).length) instance.set(props);
556
+ return instance;
557
+ }
558
+ Adopted.$$props = Component.$$props;
559
+ Adopted.$$adopted = Component;
560
+ return Adopted;
561
+ }
562
+
563
+ // hasMarkers reports range markers or a payload script: the HTML of an
564
+ // interactive root.
565
+ function hasMarkers(content) {
566
+ const walker = document.createTreeWalker(content, 0x80 | 0x1);
567
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
568
+ if (node.nodeType === 8 && (node.data.startsWith('[') || node.data === ']')) return true;
569
+ if (node.nodeType === 1 && node.localName === 'script' && node.hasAttribute('data-vela-root')) return true;
570
+ }
571
+ return false;
572
+ }
573
+
574
+ // staticHTML(html) is a component value that inserts html and creates no
575
+ // instance: it has no state, set() and updates do nothing, and destroy
576
+ // removes the nodes. HTML with range markers or a payload script is an
577
+ // interactive root; development builds reject it.
578
+ export function staticHTML(html) {
579
+ if (typeof html !== 'string') throw new TypeError('vela: staticHTML() needs the HTML as a string');
580
+ function Static(target) {
581
+ const [parent, before] = placeOf(target, 'staticHTML()');
582
+ const content = parseHTML(html);
583
+ if ($$isDev() && hasMarkers(content)) {
584
+ throw new TypeError('vela: staticHTML() got an interactive root (range markers or a payload script); use serverComponent()');
585
+ }
586
+ const nodes = [...content.childNodes];
587
+ parent.insertBefore(content, before);
588
+ const single = nodes.length === 1 && nodes[0].nodeType === 1 ? nodes[0] : null;
589
+ return {
590
+ el: single,
591
+ destroy() {
592
+ for (const node of nodes) if (node.parentNode) node.remove();
593
+ },
594
+ abandon() {},
595
+ };
596
+ }
597
+ Static.$$props = {};
598
+ return Static;
415
599
  }
package/runtime.js CHANGED
@@ -332,6 +332,55 @@ export function mount(Component, { target, props = {}, slots = {} } = {}) {
332
332
  * tick() — returns a Promise that resolves after the current microtask queue
333
333
  * drains. Useful in tests or after imperative state changes.
334
334
  */
335
+ // ── Lazy components (task 071) ─────────────────────────────────
336
+ // lazy(() => import('./X.vela')) is a component value for <vela:component>
337
+ // whose module loads on demand. The compiler adds the component ID as the
338
+ // second argument and also registers every lazy component of a module when
339
+ // the module is evaluated, so that preloadHydration() finds them by the IDs
340
+ // of the payload's "d" entries. One ID has one lazy value: it is the
341
+ // identity <vela:component> compares, before and after loading.
342
+ const $$lazyRegistry = new Map();
343
+
344
+ export function lazy(loader, id) {
345
+ if (id && $$lazyRegistry.has(id)) return $$lazyRegistry.get(id);
346
+ let pending = null;
347
+ const value = {
348
+ $$lazy: true,
349
+ id: id || '',
350
+ loaded: false,
351
+ component: null,
352
+ load() {
353
+ if (value.loaded) return Promise.resolve(value.component);
354
+ if (!pending) {
355
+ pending = Promise.resolve()
356
+ .then(loader)
357
+ .then((module) => {
358
+ const component = typeof module === 'function' ? module : module && module.default;
359
+ if (typeof component !== 'function') {
360
+ throw new TypeError(`vela: lazy component ${value.id || '(unnamed)'}: the module exports no component`);
361
+ }
362
+ value.component = component;
363
+ value.loaded = true;
364
+ return component;
365
+ })
366
+ .catch((error) => {
367
+ // A later load() tries again.
368
+ pending = null;
369
+ throw error;
370
+ });
371
+ }
372
+ return pending;
373
+ },
374
+ };
375
+ if (id) $$lazyRegistry.set(id, value);
376
+ return value;
377
+ }
378
+
379
+ // $$lazyById returns the registered lazy value of a component ID.
380
+ export function $$lazyById(id) {
381
+ return $$lazyRegistry.get(id) || null;
382
+ }
383
+
335
384
  export function tick() {
336
385
  return Promise.resolve();
337
386
  }