@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.
package/runtime.js ADDED
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Vela Runtime
3
+ * The only JS that ships to the browser — everything else is compiled away.
4
+ * ~120 lines. No dependencies.
5
+ */
6
+
7
+ // ─────────────────────────────────────────────
8
+ // Batch update mechanism
9
+ //
10
+ // When a store fires (set/update), all subscribers run inside _batchRun().
11
+ // Generated component setters call $$defer(fn) for DOM-touching work.
12
+ // $$defer pushes fn to the shared queue. Two flush paths drain it:
13
+ //
14
+ // 1. _batchRun() — when explicit batching is in progress (depth > 0),
15
+ // flush runs synchronously after the outer-most _batchRun returns.
16
+ // Used by store subscribe/notify cycles.
17
+ //
18
+ // 2. queueMicrotask — when no batch is in progress (depth === 0), the
19
+ // first $$defer schedules a microtask that drains the queue at the
20
+ // end of the current task. Subsequent synchronous $$defer calls
21
+ // reuse the already-scheduled microtask (the `scheduled` flag),
22
+ // coalescing a chain of state changes into one DOM-update pass.
23
+ //
24
+ // Why microtasks instead of synchronous fallback: a single user action
25
+ // often updates several pieces of state in a row (e.g. `loading=true;
26
+ // error=''; data=null` at the start of an async fetch). Synchronous
27
+ // $$defer would redraw three times, with intermediate visual states
28
+ // flashing. Microtask coalescing redraws once after all three setters
29
+ // return.
30
+ //
31
+ // Both flush paths share the same queue and clear `scheduled` after
32
+ // draining, so only one of them runs per coalesced batch even if
33
+ // _batchRun fires before the microtask gets its turn.
34
+ //
35
+ // The context is shared via Symbol.for only for the deprecated legacy
36
+ // generator. AST-generated components import $$defer directly from this
37
+ // module, so they never change behaviour based on global registration order.
38
+ // ─────────────────────────────────────────────
39
+
40
+ // $$runtimeContract1 is the runtime/compiler contract marker (R-12.2d).
41
+ // Every generated module that uses the runtime imports it, so a compiler and
42
+ // runtime of different contract versions fail at module load. The compiler
43
+ // changes the name when generated code needs a new or changed runtime API.
44
+ export const $$runtimeContract1 = true;
45
+
46
+ const _B = Symbol.for('vela.batch');
47
+ if (!globalThis[_B]) {
48
+ globalThis[_B] = { depth: 0, queue: [], scheduled: false };
49
+ }
50
+
51
+ // _flushQueue drains b.queue, running each deferred fn in FIFO order.
52
+ // Reentrant pushes (a deferred fn that queues another fn) are handled by
53
+ // looping until the queue is empty, so the entire transitive closure of
54
+ // updates triggered by one batch flushes in one pass.
55
+ function _flushQueue() {
56
+ const b = globalThis[_B];
57
+ b.scheduled = false;
58
+ let firstError;
59
+ while (b.queue.length) {
60
+ const q = b.queue.splice(0);
61
+ for (const f of q) {
62
+ try {
63
+ f();
64
+ } catch (error) {
65
+ if (firstError === undefined) firstError = error;
66
+ }
67
+ }
68
+ }
69
+ if (firstError !== undefined) throw firstError;
70
+ }
71
+
72
+ function _batchRun(fn) {
73
+ const b = globalThis[_B];
74
+ b.depth++;
75
+ try { fn(); }
76
+ finally {
77
+ if (--b.depth === 0 && b.queue.length) {
78
+ _flushQueue();
79
+ }
80
+ }
81
+ }
82
+
83
+ // ─────────────────────────────────────────────
84
+ // Select multiple bindings
85
+ //
86
+ // A multi-select is intentionally modelled as an array of strings. Its DOM
87
+ // semantics are membership-based: duplicate values in the model do not create
88
+ // duplicate selections, while duplicate option values (if authored) receive
89
+ // the same membership. Reading preserves DOM order, which gives application
90
+ // code a stable snapshot after a user-driven change.
91
+ //
92
+ // $$writeSelectValues is guarded at the per-option property level. That makes
93
+ // it safe for hydration and for owner-local keyed option reconciliation: a
94
+ // retained option is not touched unless its selected state actually changes.
95
+
96
+ export function $$readSelectValues(select) {
97
+ const values = [];
98
+ for (const option of select.options) {
99
+ if (option.selected) values.push(String(option.value));
100
+ }
101
+ return values;
102
+ }
103
+
104
+ // Hydration compares a multiple select by membership, not by the raw array
105
+ // returned from the DOM. Duplicate option values therefore do not replace an
106
+ // equivalent application model merely because the document contains the same
107
+ // selected value more than once.
108
+ export function $$selectValuesEqual(select, values) {
109
+ const expected = new Set(Array.isArray(values) ? values.map(String) : []);
110
+ const actual = new Set();
111
+ for (const option of select.options) {
112
+ if (!option.selected) continue;
113
+ const value = String(option.value);
114
+ if (!expected.has(value)) return false;
115
+ actual.add(value);
116
+ }
117
+ return actual.size === expected.size;
118
+ }
119
+
120
+ export function $$writeSelectValues(select, values) {
121
+ const selected = new Set(Array.isArray(values) ? values.map(String) : []);
122
+ let changed = false;
123
+ for (const option of select.options) {
124
+ const next = selected.has(String(option.value));
125
+ if (option.selected !== next) {
126
+ option.selected = next;
127
+ changed = true;
128
+ }
129
+ }
130
+ return changed;
131
+ }
132
+
133
+ // ─────────────────────────────────────────────
134
+ // Stores
135
+ //
136
+ // Store ownership is centralised here: subscriptions are synchronous,
137
+ // unsubscription is idempotent, and fan-out iterates a snapshot.
138
+
139
+ function createWritable(value) {
140
+ const subscribers = new Set();
141
+
142
+ function notify() {
143
+ let firstError;
144
+ for (const run of [...subscribers]) {
145
+ try {
146
+ run(value);
147
+ } catch (error) {
148
+ if (firstError === undefined) firstError = error;
149
+ }
150
+ }
151
+ if (firstError !== undefined) throw firstError;
152
+ }
153
+
154
+ function set(next) {
155
+ if (next === value) return;
156
+ value = next;
157
+ _batchRun(notify);
158
+ }
159
+
160
+ return {
161
+ subscribe(run) {
162
+ subscribers.add(run);
163
+ try {
164
+ run(value);
165
+ } catch (error) {
166
+ subscribers.delete(run);
167
+ throw error;
168
+ }
169
+ let active = true;
170
+ return () => {
171
+ if (!active) return;
172
+ active = false;
173
+ subscribers.delete(run);
174
+ };
175
+ },
176
+ set,
177
+ update(fn) {
178
+ set(fn(value));
179
+ },
180
+ get() {
181
+ return value;
182
+ },
183
+ };
184
+ }
185
+
186
+ /** A store whose value can be set from outside. */
187
+ export function writable(value) {
188
+ return createWritable(value);
189
+ }
190
+
191
+ /**
192
+ * A read-only store whose start callback runs once for the first subscriber
193
+ * and whose cleanup runs once after the final unsubscribe.
194
+ */
195
+ export function readable(value, start) {
196
+ const store = createWritable(value);
197
+ let subscriberCount = 0;
198
+ let stop = null;
199
+
200
+ return {
201
+ subscribe(run) {
202
+ const firstSubscriber = subscriberCount === 0;
203
+ if (firstSubscriber && start) {
204
+ const cleanup = start(store.set);
205
+ stop = typeof cleanup === 'function' ? cleanup : null;
206
+ }
207
+ let unsubscribe;
208
+ try {
209
+ unsubscribe = store.subscribe(run);
210
+ } catch (error) {
211
+ if (firstSubscriber && stop) {
212
+ const cleanup = stop;
213
+ stop = null;
214
+ cleanup();
215
+ }
216
+ throw error;
217
+ }
218
+ subscriberCount++;
219
+ let active = true;
220
+ return () => {
221
+ if (!active) return;
222
+ active = false;
223
+ unsubscribe();
224
+ subscriberCount--;
225
+ if (subscriberCount === 0 && stop) {
226
+ const cleanup = stop;
227
+ stop = null;
228
+ cleanup();
229
+ }
230
+ };
231
+ },
232
+ get: store.get,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * A read-only value derived from one or more stores. Source subscriptions stay
238
+ * live only while it has subscribers; destroy() is final and idempotent.
239
+ */
240
+ export function derived(stores, fn, initial) {
241
+ const single = !Array.isArray(stores);
242
+ const sources = single ? [stores] : stores;
243
+ const output = createWritable(initial);
244
+ let subscriberCount = 0;
245
+ let sourceUnsubs = [];
246
+ let active = false;
247
+ let destroyed = false;
248
+
249
+ function start() {
250
+ if (active || destroyed) return;
251
+ active = true;
252
+ const values = new Array(sources.length);
253
+ const inited = new Array(sources.length).fill(false);
254
+ try {
255
+ sourceUnsubs = [];
256
+ for (let index = 0; index < sources.length; index++) {
257
+ const source = sources[index];
258
+ sourceUnsubs.push(source.subscribe(value => {
259
+ values[index] = value;
260
+ inited[index] = true;
261
+ if (inited.every(Boolean)) {
262
+ output.set(single ? fn(values[0]) : fn(values));
263
+ }
264
+ }));
265
+ }
266
+ } catch (error) {
267
+ for (const unsubscribe of sourceUnsubs) unsubscribe();
268
+ sourceUnsubs = [];
269
+ active = false;
270
+ throw error;
271
+ }
272
+ }
273
+
274
+ function stop() {
275
+ if (!active) return;
276
+ active = false;
277
+ const unsubs = sourceUnsubs;
278
+ sourceUnsubs = [];
279
+ for (const unsubscribe of unsubs) unsubscribe();
280
+ }
281
+
282
+ return {
283
+ subscribe(run) {
284
+ const firstSubscriber = subscriberCount === 0;
285
+ if (firstSubscriber) start();
286
+ let unsubscribe;
287
+ try {
288
+ unsubscribe = output.subscribe(run);
289
+ } catch (error) {
290
+ if (firstSubscriber) stop();
291
+ throw error;
292
+ }
293
+ subscriberCount++;
294
+ let subscribed = true;
295
+ return () => {
296
+ if (!subscribed) return;
297
+ subscribed = false;
298
+ unsubscribe();
299
+ subscriberCount--;
300
+ if (subscriberCount === 0) stop();
301
+ };
302
+ },
303
+ get: output.get,
304
+ destroy() {
305
+ if (destroyed) return;
306
+ destroyed = true;
307
+ stop();
308
+ },
309
+ };
310
+ }
311
+
312
+ // ─────────────────────────────────────────────
313
+ // Component mounting helpers
314
+ // ─────────────────────────────────────────────
315
+
316
+ /**
317
+ * mount(Component, { target, props }) — mounts a compiled Vela component.
318
+ *
319
+ * import { mount } from './runtime.js';
320
+ * import { Counter } from './Counter.js';
321
+ *
322
+ * const app = mount(Counter, { target: document.body, props: { start: 5 } });
323
+ * // later:
324
+ * app.destroy();
325
+ */
326
+ export function mount(Component, { target, props = {}, slots = {} } = {}) {
327
+ if (!target) throw new Error('[Vela] mount() requires a target element');
328
+ return Component(target, props, slots);
329
+ }
330
+
331
+ /**
332
+ * tick() — returns a Promise that resolves after the current microtask queue
333
+ * drains. Useful in tests or after imperative state changes.
334
+ */
335
+ export function tick() {
336
+ return Promise.resolve();
337
+ }
338
+
339
+ // ─────────────────────────────────────────────
340
+ // Component helper: $$defer
341
+ //
342
+ // AST-generated components import this helper explicitly. It is also
343
+ // registered on globalThis for generated modules from the deprecated legacy
344
+ // code generator, which did not emit a runtime import.
345
+ //
346
+ // Behavior summary:
347
+ // depth > 0 → push to queue; _batchRun's flush will drain it
348
+ // depth === 0 → push to queue; schedule a microtask flush if not already
349
+ // scheduled. Coalesces a chain of state changes into one
350
+ // redraw at the end of the current task.
351
+ //
352
+ // The deprecated generator retains its own synchronous fallback when no
353
+ // runtime module is loaded. That compatibility behaviour does not apply to
354
+ // the AST generator: its named import guarantees this implementation runs.
355
+ // ─────────────────────────────────────────────
356
+
357
+ // _scheduleFlush asks the JS engine to run _flushQueue at the end of the
358
+ // current task. queueMicrotask is the standard API; the Promise-then form
359
+ // is a fallback for older runtimes that lack it. Both are microtask-class:
360
+ // they fire before the next macrotask (setTimeout, I/O), so flushed updates
361
+ // are visible before any subsequent user interaction.
362
+ const _scheduleFlush = (typeof queueMicrotask === 'function')
363
+ ? queueMicrotask
364
+ : (fn) => Promise.resolve().then(fn);
365
+
366
+ export function $$defer(fn) {
367
+ const b = globalThis[Symbol.for('vela.batch')];
368
+ b.queue.push(fn);
369
+ if (b.depth > 0) {
370
+ // Inside an explicit batch — _batchRun's depth-decrement will flush.
371
+ return;
372
+ }
373
+ if (!b.scheduled) {
374
+ b.scheduled = true;
375
+ _scheduleFlush(_flushQueue);
376
+ }
377
+ }
378
+ globalThis[Symbol.for('vela.defer')] = $$defer;
379
+
380
+
381
+ const DEV = process.env.NODE_ENV !== 'production';
382
+
383
+ export function warn(msg) {
384
+ if (DEV) console.warn('[Vela]', msg);
385
+ }
386
+
387
+ // $$isDev reports a development build; production builds replace DEV with
388
+ // a constant, so development-only code becomes dead code.
389
+ export function $$isDev() {
390
+ return DEV;
391
+ }
392
+
393
+
394
+ // ── Compiler runtime helpers ────────────────────────────────────
395
+ // These were previously inlined into every generated component.
396
+ // A compiled component imports whichever ones it uses, so the
397
+ // helper body ships once instead of per file.
398
+
399
+ // $$insertHTML parses an HTML string into `target`. A
400
+ // DocumentFragment (nodeType 11) has no innerHTML setter, so the
401
+ // HTML is routed through a <template>; an element uses the faster
402
+ // insertAdjacentHTML.
403
+ export function $$insertHTML($$t, $$h) {
404
+ if ($$t.nodeType === 11) {
405
+ const $$tmp = document.createElement('template');
406
+ $$tmp.innerHTML = $$h;
407
+ $$t.appendChild($$tmp.content);
408
+ } else {
409
+ $$t.insertAdjacentHTML('beforeend', $$h);
410
+ }
411
+ }
412
+
413
+ // $$proxy wraps a value in a recursive, WeakMap-cached Proxy so a
414
+ // nested mutation fires the same `notify` path as a whole-value
415
+ // reassignment. Backs $state.deep.
416
+ //
417
+ // Notification is value-aware: assigning an equal value or deleting
418
+ // a missing key is a no-op. Native mutating Array methods use one
419
+ // local transaction, so their several Proxy traps publish at most
420
+ // one component invalidation.
421
+ export function $$proxy(val, notify) {
422
+ if (val === null || typeof val !== 'object') return val;
423
+ const cache = new WeakMap();
424
+ const rawByProxy = new WeakMap();
425
+ const arrayMethods = new WeakMap();
426
+ let mutationDepth = 0;
427
+ let mutationPending = false;
428
+
429
+ const mutatingArrayMethods = new Set([
430
+ 'copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'sort',
431
+ 'splice', 'unshift',
432
+ ]);
433
+
434
+ const unwrap = (value) => rawByProxy.get(value) || value;
435
+ const changed = (mutation) => {
436
+ if (mutationDepth > 0) {
437
+ mutationPending = true;
438
+ return;
439
+ }
440
+ notify(mutation);
441
+ };
442
+ const runArrayMutation = (target, key, receiver, method, path) => {
443
+ let methods = arrayMethods.get(target);
444
+ if (!methods) {
445
+ methods = new Map();
446
+ arrayMethods.set(target, methods);
447
+ }
448
+ const cached = methods.get(key);
449
+ if (cached && cached.method === method) return cached.wrapped;
450
+ const wrapped = function(...args) {
451
+ // Preserve native extraction/call semantics. Only a method invoked with
452
+ // this proxy as its receiver owns this deep-state notification batch;
453
+ // `const push = list.push; push()` must still throw like Array#push.
454
+ if (this !== receiver) return Reflect.apply(method, this, args.map(unwrap));
455
+ mutationDepth++;
456
+ try {
457
+ return Reflect.apply(method, receiver, args.map(unwrap));
458
+ } finally {
459
+ mutationDepth--;
460
+ if (mutationDepth === 0 && mutationPending) {
461
+ mutationPending = false;
462
+ notify({ kind: 'array', path: path.slice(), method: key });
463
+ }
464
+ }
465
+ };
466
+ methods.set(key, { method, wrapped });
467
+ return wrapped;
468
+ };
469
+ const wrap = (v, path = []) => {
470
+ if (v === null || typeof v !== 'object') return v;
471
+ // Exotic built-ins (Promise, Date, RegExp, Map, Set, WeakMap,
472
+ // WeakSet, Error, ArrayBuffer / TypedArrays) rely on internal
473
+ // slots that Proxy receivers cannot satisfy — wrapping them
474
+ // breaks any method call ("Method Promise.prototype.then
475
+ // called on incompatible receiver"). Return such values
476
+ // unwrapped; deep reactivity for plain objects and arrays is
477
+ // unaffected.
478
+ if (v instanceof Promise) return v;
479
+ if (v instanceof Date) return v;
480
+ if (v instanceof RegExp) return v;
481
+ if (v instanceof Map) return v;
482
+ if (v instanceof Set) return v;
483
+ if (v instanceof WeakMap) return v;
484
+ if (v instanceof WeakSet) return v;
485
+ if (v instanceof Error) return v;
486
+ if (v instanceof ArrayBuffer) return v;
487
+ if (ArrayBuffer.isView && ArrayBuffer.isView(v)) return v;
488
+ let p = cache.get(v);
489
+ if (p) return p;
490
+ p = new Proxy(v, {
491
+ get(t, k, receiver) {
492
+ // Brand: lets a component prop setter recognise a deep
493
+ // ($state.deep) proxy and skip the identity-equality guard
494
+ // that would otherwise suppress a re-render after an
495
+ // in-place mutation (the reference is unchanged but the
496
+ // contents are not).
497
+ if (k === $$PROXY_BRAND) return true;
498
+ const value = Reflect.get(t, k, receiver);
499
+ if (Array.isArray(t) && typeof k === 'string' &&
500
+ mutatingArrayMethods.has(k) && typeof value === 'function') {
501
+ return runArrayMutation(t, k, receiver, value, path);
502
+ }
503
+ return wrap(value, path.concat(k));
504
+ },
505
+ set(t, k, x, receiver) {
506
+ const hadOwn = Object.prototype.hasOwnProperty.call(t, k);
507
+ const before = Reflect.get(t, k, receiver);
508
+ const ok = Reflect.set(t, k, unwrap(x), receiver);
509
+ const hasOwn = Object.prototype.hasOwnProperty.call(t, k);
510
+ if (ok && (hadOwn !== hasOwn ||
511
+ !Object.is(before, Reflect.get(t, k, receiver)))) changed({ kind: 'set', path: path.concat(k) });
512
+ return ok;
513
+ },
514
+ deleteProperty(t, k) {
515
+ if (!Object.prototype.hasOwnProperty.call(t, k)) return true;
516
+ const ok = Reflect.deleteProperty(t, k);
517
+ if (ok) changed({ kind: 'delete', path: path.concat(k) });
518
+ return ok;
519
+ },
520
+ });
521
+ cache.set(v, p);
522
+ rawByProxy.set(p, v);
523
+ return p;
524
+ };
525
+ return wrap(val);
526
+ }
527
+
528
+ // $$PROXY_BRAND is a cross-realm symbol read by $$isProxy. Using
529
+ // Symbol.for keeps it stable across module instances.
530
+ const $$PROXY_BRAND = Symbol.for('vela.proxy');
531
+
532
+ // $$isProxy reports whether v is a deep-reactive proxy produced by
533
+ // $$proxy. A component prop setter uses this to decide whether an
534
+ // identity-equal new value might still have mutated in place.
535
+ export function $$isProxy(v) {
536
+ return v !== null && typeof v === 'object' && v[$$PROXY_BRAND] === true;
537
+ }
538
+
539
+ // $$dispatch fires a bubbling CustomEvent from a component's mount
540
+ // target. Exported as a (target, …) function; a generated
541
+ // component binds it to its own $$target.
542
+ export function $$dispatch($$target, name, detail) {
543
+ $$target.dispatchEvent(
544
+ new CustomEvent(name, { detail, bubbles: true }));
545
+ }
546
+ // $$eachRowsForMutations returns the distinct row indexes that can be safely
547
+ // refreshed without a full keyed reconciliation. null is the conservative
548
+ // fallback: structural array changes, key writes, unknown records, and empty
549
+ // batches must retain the normal keyed diff.
550
+ export function $$eachRowsForMutations(mutations, keyField) {
551
+ if (!Array.isArray(mutations) || mutations.length === 0) return null;
552
+ const rows = new Set();
553
+ for (const mutation of mutations) {
554
+ if (!mutation || (mutation.kind !== "set" && mutation.kind !== "delete") ||
555
+ !Array.isArray(mutation.path) || mutation.path.length < 2) return null;
556
+ const [rawIndex, field] = mutation.path;
557
+ const index = typeof rawIndex === "string" ? Number(rawIndex) : rawIndex;
558
+ if (!Number.isInteger(index) || index < 0 || field === keyField) return null;
559
+ rows.add(index);
560
+ }
561
+ return [...rows];
562
+ }