tova 0.2.9 → 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.
@@ -363,9 +363,9 @@ export function watch(getter, callback, options = {}) {
363
363
  const effect = createEffect(() => {
364
364
  const newValue = getter();
365
365
  if (initialized) {
366
- callback(newValue, oldValue);
366
+ untrack(() => callback(newValue, oldValue));
367
367
  } else if (options.immediate) {
368
- callback(newValue, undefined);
368
+ untrack(() => callback(newValue, undefined));
369
369
  }
370
370
  oldValue = newValue;
371
371
  initialized = true;
@@ -447,29 +447,41 @@ export function createErrorBoundary(options = {}) {
447
447
  return { error, run, reset };
448
448
  }
449
449
 
450
- export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0 }) {
450
+ let __errorBoundaryIdCounter = 0;
451
+
452
+ export function ErrorBoundary({ fallback, children, onError, onReset, onErrorCleared, retry = 0 }) {
451
453
  const [error, setError] = createSignal(null);
452
454
  const [retryCount, setRetryCount] = createSignal(0);
455
+ const boundaryId = ++__errorBoundaryIdCounter;
456
+ let lastErrorId = 0;
453
457
 
454
458
  function handleError(e) {
455
459
  const stack = buildComponentStack();
456
- if (e && typeof e === 'object') e.__tovaComponentStack = stack;
460
+ const errorId = `EB${boundaryId}-${++lastErrorId}`;
461
+
462
+ if (e && typeof e === 'object') {
463
+ e.__tovaComponentStack = stack;
464
+ e.__tovaErrorId = errorId;
465
+ }
466
+
457
467
  if (retryCount() < retry) {
458
468
  setRetryCount(c => c + 1);
459
469
  setError(null); // clear to re-trigger render
460
470
  return;
461
471
  }
462
472
  setError(e);
463
- if (onError) onError({ error: e, componentStack: stack });
473
+ if (onError) onError({ error: e, componentStack: stack, errorId, retryCount: retryCount() });
464
474
  }
465
475
 
466
- pushErrorHandler(handleError);
476
+ function resetBoundary() {
477
+ setRetryCount(0);
478
+ setError(null);
479
+ if (onReset) onReset();
480
+ }
467
481
 
468
482
  // Return a reactive wrapper that switches between children and fallback
469
483
  const childContent = children && children.length === 1 ? children[0] : tova_fragment(children || []);
470
484
 
471
- popErrorHandler();
472
-
473
485
  const vnode = {
474
486
  __tova: true,
475
487
  tag: '__dynamic',
@@ -477,19 +489,20 @@ export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0
477
489
  children: [],
478
490
  _fallback: fallback,
479
491
  _componentName: 'ErrorBoundary',
492
+ _errorHandler: handleError, // Active during __dynamic effect render cycle
480
493
  compute: () => {
481
494
  const err = error();
482
495
  if (err) {
483
496
  // Render fallback — if fallback itself throws, propagate to parent boundary
484
497
  try {
498
+ const errorId = err && typeof err === 'object' ? err.__tovaErrorId : null;
485
499
  return typeof fallback === 'function'
486
500
  ? fallback({
487
501
  error: err,
488
- reset: () => {
489
- setRetryCount(0);
490
- setError(null);
491
- if (onReset) onReset();
492
- },
502
+ errorId,
503
+ retryCount: retryCount(),
504
+ componentStack: err && typeof err === 'object' ? err.__tovaComponentStack : [],
505
+ reset: resetBoundary,
493
506
  })
494
507
  : fallback;
495
508
  } catch (fallbackError) {
@@ -500,6 +513,10 @@ export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0
500
513
  return null;
501
514
  }
502
515
  }
516
+ // Children rendered successfully — fire onErrorCleared if we recovered from an error
517
+ if (onErrorCleared && lastErrorId > 0 && retryCount() === 0) {
518
+ queueMicrotask(() => onErrorCleared());
519
+ }
503
520
  return childContent;
504
521
  },
505
522
  };
@@ -507,6 +524,58 @@ export function ErrorBoundary({ fallback, children, onError, onReset, retry = 0
507
524
  return vnode;
508
525
  }
509
526
 
527
+ // Built-in ErrorInfo component — renders a formatted error display
528
+ // Usage: <ErrorBoundary fallback={fn(props) ErrorInfo(props)} />
529
+ export function ErrorInfo({ error, errorId, componentStack, reset, retryCount }) {
530
+ const message = error instanceof Error ? error.message : String(error);
531
+ const stackTrace = error instanceof Error && error.stack ? error.stack : '';
532
+ const compStack = (componentStack || []).join(' > ');
533
+
534
+ const children = [
535
+ tova_el('h3', { style: { margin: '0 0 8px 0', color: '#e53e3e' } }, ['Something went wrong']),
536
+ tova_el('p', { style: { margin: '4px 0', fontFamily: 'monospace', fontSize: '14px' } }, [message]),
537
+ ];
538
+
539
+ if (compStack) {
540
+ children.push(
541
+ tova_el('p', { style: { margin: '4px 0', fontSize: '12px', color: '#718096' } }, [
542
+ 'Component: ', compStack
543
+ ])
544
+ );
545
+ }
546
+
547
+ if (errorId) {
548
+ children.push(
549
+ tova_el('p', { style: { margin: '4px 0', fontSize: '11px', color: '#a0aec0' } }, [
550
+ 'Error ID: ', errorId
551
+ ])
552
+ );
553
+ }
554
+
555
+ if (stackTrace) {
556
+ children.push(
557
+ tova_el('details', { style: { marginTop: '8px', fontSize: '12px' } }, [
558
+ tova_el('summary', { style: { cursor: 'pointer', color: '#4a5568' } }, ['Stack trace']),
559
+ tova_el('pre', { style: { margin: '4px 0', padding: '8px', background: '#1a202c', color: '#e2e8f0', borderRadius: '4px', overflow: 'auto', fontSize: '11px', maxHeight: '200px' } }, [stackTrace]),
560
+ ])
561
+ );
562
+ }
563
+
564
+ if (reset) {
565
+ children.push(
566
+ tova_el('button', {
567
+ style: { marginTop: '8px', padding: '6px 16px', background: '#3182ce', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' },
568
+ onClick: reset,
569
+ }, [retryCount > 0 ? 'Retry again' : 'Try again'])
570
+ );
571
+ }
572
+
573
+ return tova_el('div', {
574
+ style: { padding: '16px', border: '1px solid #fed7d7', borderRadius: '8px', background: '#fff5f5', color: '#2d3748', fontFamily: 'system-ui, -apple-system, sans-serif' },
575
+ role: 'alert',
576
+ }, children);
577
+ }
578
+
510
579
  // ─── Dynamic Component ──────────────────────────────────
511
580
  // Renders a component dynamically based on a reactive signal.
512
581
  // Usage: Dynamic({ component: mySignal, ...props })
@@ -547,6 +616,7 @@ export function Portal({ target, children }) {
547
616
 
548
617
  export function lazy(loader) {
549
618
  let resolved = null;
619
+ let loadError = null;
550
620
  let promise = null;
551
621
 
552
622
  return function LazyWrapper(props) {
@@ -554,28 +624,28 @@ export function lazy(loader) {
554
624
  return resolved(props);
555
625
  }
556
626
 
557
- const [comp, setComp] = createSignal(null);
558
- const [err, setErr] = createSignal(null);
559
-
560
627
  if (!promise) {
561
628
  promise = loader()
562
629
  .then(mod => {
563
630
  resolved = mod.default || mod;
564
- setComp(() => resolved);
565
631
  })
566
- .catch(e => setErr(e));
632
+ .catch(e => { loadError = e; });
567
633
  }
568
634
 
635
+ const [tick, setTick] = createSignal(0);
636
+
637
+ // Trigger re-render when promise settles
638
+ promise.then(() => setTick(1)).catch(() => setTick(1));
639
+
569
640
  return {
570
641
  __tova: true,
571
642
  tag: '__dynamic',
572
643
  props: {},
573
644
  children: [],
574
645
  compute: () => {
575
- const e = err();
576
- if (e) return tova_el('span', { className: 'tova-error' }, [String(e)]);
577
- const c = comp();
578
- if (c) return c(props);
646
+ tick(); // Track for reactivity
647
+ if (loadError) return tova_el('span', { className: 'tova-error' }, [String(loadError)]);
648
+ if (resolved) return resolved(props);
579
649
  // Fallback while loading
580
650
  return props && props.fallback ? props.fallback : null;
581
651
  },
@@ -631,6 +701,100 @@ export function tova_fragment(children) {
631
701
  return { __tova: true, tag: '__fragment', props: {}, children };
632
702
  }
633
703
 
704
+ // ─── Transitions ──────────────────────────────────────────
705
+ // CSS transition directives for mount/unmount animations.
706
+ // Usage: tova_transition(vnode, "fade", { duration: 300 })
707
+
708
+ const TRANSITION_DEFAULTS = {
709
+ fade: { duration: 200, easing: 'ease' },
710
+ slide: { duration: 300, easing: 'ease-out', axis: 'y' },
711
+ scale: { duration: 200, easing: 'ease' },
712
+ fly: { duration: 300, easing: 'ease-out', x: 0, y: -20 },
713
+ };
714
+
715
+ function getTransitionCSS(name, config, phase) {
716
+ const opts = { ...TRANSITION_DEFAULTS[name], ...config };
717
+ const dur = opts.duration + 'ms';
718
+ const ease = opts.easing;
719
+
720
+ switch (name) {
721
+ case 'fade':
722
+ if (phase === 'enter-from' || phase === 'leave-to') {
723
+ return { opacity: '0', transition: `opacity ${dur} ${ease}` };
724
+ }
725
+ return { opacity: '1', transition: `opacity ${dur} ${ease}` };
726
+
727
+ case 'slide': {
728
+ const axis = opts.axis || 'y';
729
+ const prop = axis === 'x' ? 'translateX' : 'translateY';
730
+ const dist = (opts.distance || 20) + 'px';
731
+ if (phase === 'enter-from' || phase === 'leave-to') {
732
+ return { transform: `${prop}(${dist})`, opacity: '0', transition: `transform ${dur} ${ease}, opacity ${dur} ${ease}` };
733
+ }
734
+ return { transform: `${prop}(0)`, opacity: '1', transition: `transform ${dur} ${ease}, opacity ${dur} ${ease}` };
735
+ }
736
+
737
+ case 'scale':
738
+ if (phase === 'enter-from' || phase === 'leave-to') {
739
+ return { transform: 'scale(0)', opacity: '0', transition: `transform ${dur} ${ease}, opacity ${dur} ${ease}` };
740
+ }
741
+ return { transform: 'scale(1)', opacity: '1', transition: `transform ${dur} ${ease}, opacity ${dur} ${ease}` };
742
+
743
+ case 'fly': {
744
+ const x = (opts.x || 0) + 'px';
745
+ const y = (opts.y || -20) + 'px';
746
+ if (phase === 'enter-from' || phase === 'leave-to') {
747
+ return { transform: `translate(${x}, ${y})`, opacity: '0', transition: `transform ${dur} ${ease}, opacity ${dur} ${ease}` };
748
+ }
749
+ return { transform: 'translate(0, 0)', opacity: '1', transition: `transform ${dur} ${ease}, opacity ${dur} ${ease}` };
750
+ }
751
+
752
+ default:
753
+ return {};
754
+ }
755
+ }
756
+
757
+ export function tova_transition(vnode, name, config = {}) {
758
+ if (!vnode || !vnode.__tova) return vnode;
759
+ vnode._transition = { name, config };
760
+ return vnode;
761
+ }
762
+
763
+ // Apply enter transition to a DOM element after render
764
+ function applyEnterTransition(el, trans) {
765
+ if (!trans) return;
766
+ const fromStyles = getTransitionCSS(trans.name, trans.config, 'enter-from');
767
+ const toStyles = getTransitionCSS(trans.name, trans.config, 'enter-to');
768
+
769
+ // Set initial state
770
+ Object.assign(el.style, fromStyles);
771
+
772
+ // Force reflow, then apply target state
773
+ requestAnimationFrame(() => {
774
+ requestAnimationFrame(() => {
775
+ Object.assign(el.style, toStyles);
776
+ });
777
+ });
778
+ }
779
+
780
+ // Apply leave transition and return a Promise that resolves when done
781
+ function applyLeaveTransition(el, trans) {
782
+ if (!trans) return Promise.resolve();
783
+ const duration = (trans.config && trans.config.duration) || TRANSITION_DEFAULTS[trans.name]?.duration || 200;
784
+ const toStyles = getTransitionCSS(trans.name, trans.config, 'leave-to');
785
+ Object.assign(el.style, toStyles);
786
+
787
+ return new Promise(resolve => {
788
+ const handler = () => {
789
+ el.removeEventListener('transitionend', handler);
790
+ resolve();
791
+ };
792
+ el.addEventListener('transitionend', handler);
793
+ // Fallback timeout in case transitionend doesn't fire
794
+ setTimeout(resolve, duration + 50);
795
+ });
796
+ }
797
+
634
798
  // Inject a key prop into a vnode for keyed reconciliation
635
799
  export function tova_keyed(key, vnode) {
636
800
  if (vnode && vnode.__tova) {
@@ -741,8 +905,17 @@ function insertRendered(parent, rendered, ref, owner) {
741
905
  // Clear a marker's content from the DOM and reset __tovaNodes
742
906
  function clearMarkerContent(marker) {
743
907
  for (const node of marker.__tovaNodes) {
744
- disposeNode(node);
745
- if (node.parentNode) node.parentNode.removeChild(node);
908
+ // If element has a leave transition, animate out before removing
909
+ if (node.__tovaTransition && node.nodeType === 1) {
910
+ const el = node;
911
+ applyLeaveTransition(el, el.__tovaTransition).then(() => {
912
+ disposeNode(el);
913
+ if (el.parentNode) el.parentNode.removeChild(el);
914
+ });
915
+ } else {
916
+ disposeNode(node);
917
+ if (node.parentNode) node.parentNode.removeChild(node);
918
+ }
746
919
  }
747
920
  marker.__tovaNodes = [];
748
921
  }
@@ -861,22 +1034,36 @@ export function render(vnode) {
861
1034
  frag.appendChild(marker);
862
1035
 
863
1036
  let prevDispose = null;
1037
+ const errHandler = vnode._errorHandler || null;
864
1038
  createEffect(() => {
865
- const inner = vnode.compute();
866
- const parent = marker.parentNode;
867
- const ref = nextSiblingAfterMarker(marker);
1039
+ if (errHandler) pushErrorHandler(errHandler);
1040
+ try {
1041
+ const inner = vnode.compute();
1042
+ const parent = marker.parentNode;
1043
+ const ref = nextSiblingAfterMarker(marker);
1044
+
1045
+ if (prevDispose) {
1046
+ prevDispose();
1047
+ prevDispose = null;
1048
+ }
1049
+ clearMarkerContent(marker);
868
1050
 
869
- if (prevDispose) {
870
- prevDispose();
871
- prevDispose = null;
1051
+ createRoot((dispose) => {
1052
+ prevDispose = dispose;
1053
+ const rendered = render(inner);
1054
+ marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);
1055
+ });
1056
+ } catch (e) {
1057
+ if (errHandler) {
1058
+ errHandler(e);
1059
+ } else if (currentErrorHandler) {
1060
+ currentErrorHandler(e);
1061
+ } else {
1062
+ console.error('Uncaught error during render:', e);
1063
+ }
1064
+ } finally {
1065
+ if (errHandler) popErrorHandler();
872
1066
  }
873
- clearMarkerContent(marker);
874
-
875
- createRoot((dispose) => {
876
- prevDispose = dispose;
877
- const rendered = render(inner);
878
- marker.__tovaNodes = insertRendered(parent, rendered, ref, marker);
879
- });
880
1067
  });
881
1068
 
882
1069
  return frag;
@@ -919,6 +1106,12 @@ export function render(vnode) {
919
1106
  // Store vnode reference for patching
920
1107
  el.__vnode = vnode;
921
1108
 
1109
+ // Apply enter transition if present
1110
+ if (vnode._transition) {
1111
+ el.__tovaTransition = vnode._transition;
1112
+ applyEnterTransition(el, vnode._transition);
1113
+ }
1114
+
922
1115
  return el;
923
1116
  }
924
1117
 
@@ -965,6 +1158,14 @@ function applyPropValue(el, key, val) {
965
1158
  } else if (key === 'disabled' || key === 'readOnly' || key === 'hidden') {
966
1159
  el[key] = !!val;
967
1160
  } else if (key === 'style' && typeof val === 'object') {
1161
+ // Clear old properties not present in new style object
1162
+ for (let i = el.style.length - 1; i >= 0; i--) {
1163
+ const prop = el.style[i];
1164
+ const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1165
+ if (!(prop in val) && !(camel in val)) {
1166
+ el.style.removeProperty(prop);
1167
+ }
1168
+ }
968
1169
  Object.assign(el.style, val);
969
1170
  } else {
970
1171
  const s = val == null ? '' : String(val);
@@ -1150,11 +1351,7 @@ function patchPositionalInMarker(marker, newChildren) {
1150
1351
  if (oldNodes[i].parentNode === parent) parent.removeChild(oldNodes[i]);
1151
1352
  }
1152
1353
 
1153
- marker.__tovaNodes = oldNodes.slice(0, Math.max(newCount, oldCount > newCount ? newCount : oldNodes.length));
1154
- // Simplify: rebuild __tovaNodes from what should remain
1155
- if (newCount <= oldCount) {
1156
- marker.__tovaNodes = oldNodes.slice(0, newCount);
1157
- }
1354
+ marker.__tovaNodes = oldNodes.slice(0, newCount);
1158
1355
  }
1159
1356
 
1160
1357
  // Keyed reconciliation for children of an element (not marker-based)
@@ -0,0 +1,81 @@
1
+ // Advanced collection data structures for Tova
2
+ // These are the real JS implementations used for testing and reference.
3
+
4
+ export class OrderedDict {
5
+ constructor(entries) {
6
+ this._map = new Map(entries || []);
7
+ }
8
+ get(key) { return this._map.has(key) ? this._map.get(key) : null; }
9
+ set(key, value) { const m = new Map(this._map); m.set(key, value); return new OrderedDict([...m]); }
10
+ delete(key) { const m = new Map(this._map); m.delete(key); return new OrderedDict([...m]); }
11
+ has(key) { return this._map.has(key); }
12
+ keys() { return [...this._map.keys()]; }
13
+ values() { return [...this._map.values()]; }
14
+ entries() { return [...this._map.entries()]; }
15
+ get length() { return this._map.size; }
16
+ [Symbol.iterator]() { return this._map[Symbol.iterator](); }
17
+ toString() { return 'OrderedDict(' + this._map.size + ' entries)'; }
18
+ }
19
+
20
+ export class DefaultDict {
21
+ constructor(defaultFn) {
22
+ this._map = new Map();
23
+ this._default = defaultFn;
24
+ }
25
+ get(key) {
26
+ if (!this._map.has(key)) {
27
+ this._map.set(key, this._default());
28
+ }
29
+ return this._map.get(key);
30
+ }
31
+ set(key, value) { this._map.set(key, value); return this; }
32
+ has(key) { return this._map.has(key); }
33
+ delete(key) { this._map.delete(key); return this; }
34
+ keys() { return [...this._map.keys()]; }
35
+ values() { return [...this._map.values()]; }
36
+ entries() { return [...this._map.entries()]; }
37
+ get length() { return this._map.size; }
38
+ [Symbol.iterator]() { return this._map[Symbol.iterator](); }
39
+ toString() { return 'DefaultDict(' + this._map.size + ' entries)'; }
40
+ }
41
+
42
+ export class Counter {
43
+ constructor(items) {
44
+ this._counts = new Map();
45
+ if (items) {
46
+ for (const item of items) {
47
+ const k = item;
48
+ this._counts.set(k, (this._counts.get(k) || 0) + 1);
49
+ }
50
+ }
51
+ }
52
+ count(item) { return this._counts.get(item) || 0; }
53
+ total() { let s = 0; for (const v of this._counts.values()) s += v; return s; }
54
+ most_common(n) {
55
+ const sorted = [...this._counts.entries()].sort((a, b) => b[1] - a[1]);
56
+ return n !== undefined ? sorted.slice(0, n) : sorted;
57
+ }
58
+ keys() { return [...this._counts.keys()]; }
59
+ values() { return [...this._counts.values()]; }
60
+ entries() { return [...this._counts.entries()]; }
61
+ has(item) { return this._counts.has(item); }
62
+ get length() { return this._counts.size; }
63
+ [Symbol.iterator]() { return this._counts[Symbol.iterator](); }
64
+ toString() { return 'Counter(' + this._counts.size + ' items)'; }
65
+ }
66
+
67
+ export class Deque {
68
+ constructor(items) {
69
+ this._items = items ? [...items] : [];
70
+ }
71
+ push_back(val) { return new Deque([...this._items, val]); }
72
+ push_front(val) { return new Deque([val, ...this._items]); }
73
+ pop_back() { if (this._items.length === 0) return [null, this]; const items = this._items.slice(0, -1); return [this._items[this._items.length - 1], new Deque(items)]; }
74
+ pop_front() { if (this._items.length === 0) return [null, this]; return [this._items[0], new Deque(this._items.slice(1))]; }
75
+ peek_front() { return this._items.length > 0 ? this._items[0] : null; }
76
+ peek_back() { return this._items.length > 0 ? this._items[this._items.length - 1] : null; }
77
+ get length() { return this._items.length; }
78
+ toArray() { return [...this._items]; }
79
+ [Symbol.iterator]() { return this._items[Symbol.iterator](); }
80
+ toString() { return 'Deque(' + this._items.length + ' items)'; }
81
+ }