tova 0.4.6 → 0.4.7

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.
@@ -25,6 +25,9 @@ let pendingEffects = new Set();
25
25
  let batchDepth = 0;
26
26
  let flushing = false;
27
27
 
28
+ // Reusable array for flush cycle — avoids allocation on every flush
29
+ let _flushBuf = [];
30
+
28
31
  function flush() {
29
32
  if (flushing) return; // prevent re-entrant flush
30
33
  flushing = true;
@@ -52,14 +55,17 @@ function flush() {
52
55
  const toRun = pendingEffects;
53
56
  pendingEffects = new Set();
54
57
  // Sort by depth (parents first) to avoid redundant child re-runs
58
+ // Reuse buffer to reduce GC pressure
55
59
  if (toRun.size > 1) {
56
- const sorted = Array.from(toRun);
57
- sorted.sort((a, b) => (a._depth || 0) - (b._depth || 0));
58
- for (const effect of sorted) {
59
- if (!effect._disposed) {
60
- effect();
60
+ _flushBuf.length = 0;
61
+ for (const effect of toRun) _flushBuf.push(effect);
62
+ _flushBuf.sort((a, b) => (a._depth || 0) - (b._depth || 0));
63
+ for (let i = 0; i < _flushBuf.length; i++) {
64
+ if (!_flushBuf[i]._disposed) {
65
+ _flushBuf[i]();
61
66
  }
62
67
  }
68
+ _flushBuf.length = 0;
63
69
  } else {
64
70
  for (const effect of toRun) {
65
71
  if (!effect._disposed) {
@@ -626,6 +632,7 @@ export function Dynamic({ component, ...rest }) {
626
632
  // ─── Portal ─────────────────────────────────────────────
627
633
  // Renders children into a different DOM target.
628
634
  // Usage: Portal({ target: "#modal-root", children })
635
+ // Cleans up children from the target when the component unmounts.
629
636
 
630
637
  export function Portal({ target, children }) {
631
638
  return {
@@ -633,6 +640,7 @@ export function Portal({ target, children }) {
633
640
  tag: '__portal',
634
641
  props: { target },
635
642
  children: children || [],
643
+ _portalCleanup: true, // Signal to render() to register cleanup
636
644
  };
637
645
  }
638
646
 
@@ -678,6 +686,8 @@ export function lazy(loader) {
678
686
  let resolved = null;
679
687
  let loadError = null;
680
688
  let promise = null;
689
+ // Signal is shared across all renders of this lazy component (not per-call)
690
+ const [tick, setTick] = createSignal(0);
681
691
 
682
692
  return function LazyWrapper(props) {
683
693
  if (resolved) {
@@ -693,18 +703,15 @@ export function lazy(loader) {
693
703
  .then(mod => {
694
704
  resolved = mod.default || mod;
695
705
  if (suspense) suspense.resolve();
706
+ setTick(1);
696
707
  })
697
708
  .catch(e => {
698
709
  loadError = e;
699
710
  if (suspense) suspense.resolve();
711
+ setTick(1);
700
712
  });
701
713
  }
702
714
 
703
- const [tick, setTick] = createSignal(0);
704
-
705
- // Trigger re-render when promise settles
706
- promise.then(() => setTick(1)).catch(() => setTick(1));
707
-
708
715
  return {
709
716
  __tova: true,
710
717
  tag: '__dynamic',
@@ -748,17 +755,209 @@ export function inject(context) {
748
755
  return context._default;
749
756
  }
750
757
 
758
+ // ─── Head Component ──────────────────────────────────────
759
+ // Declarative document head management.
760
+ // Usage: Head({ children: [tova_el('title', {}, ['My Page']), tova_el('meta', {name: 'description', content: '...'})] })
761
+ // Components can render <Head> to set title, meta, link, and script tags in <head>.
762
+ // When the component unmounts, its head contributions are removed.
763
+
764
+ const __tovaHeadTags = [];
765
+
766
+ export function Head({ children }) {
767
+ if (typeof document === 'undefined') return null;
768
+
769
+ const addedElements = [];
770
+ const childList = Array.isArray(children) ? children : [children];
771
+
772
+ for (const child of childList) {
773
+ if (!child || !child.__tova) continue;
774
+ const tag = child.tag;
775
+ const props = child.props || {};
776
+ const text = child.children && child.children.length > 0 ? child.children.join('') : null;
777
+
778
+ if (tag === 'title') {
779
+ // Special case: update document.title directly
780
+ const prevTitle = document.title;
781
+ document.title = text || '';
782
+ addedElements.push({ type: 'title', prev: prevTitle });
783
+ } else {
784
+ const el = document.createElement(tag);
785
+ for (const [key, val] of Object.entries(props)) {
786
+ if (key.startsWith('on') || key === 'key' || key === 'ref') continue;
787
+ const attrName = key === 'className' ? 'class' : key;
788
+ const attrVal = typeof val === 'function' ? val() : val;
789
+ if (attrVal !== false && attrVal != null) {
790
+ el.setAttribute(attrName, String(attrVal));
791
+ }
792
+ }
793
+ if (text) el.textContent = text;
794
+ document.head.appendChild(el);
795
+ addedElements.push({ type: 'element', el });
796
+ }
797
+ }
798
+
799
+ // Register cleanup: remove added elements when component unmounts
800
+ if (currentOwner) {
801
+ const cleanup = () => {
802
+ for (const item of addedElements) {
803
+ if (item.type === 'element') {
804
+ if (typeof item.el.remove === 'function') {
805
+ item.el.remove();
806
+ } else if (item.el.parentNode && typeof item.el.parentNode.removeChild === 'function') {
807
+ item.el.parentNode.removeChild(item.el);
808
+ }
809
+ } else if (item.type === 'title') {
810
+ document.title = item.prev;
811
+ }
812
+ }
813
+ };
814
+ if (!currentOwner._cleanups) currentOwner._cleanups = [];
815
+ currentOwner._cleanups.push(cleanup);
816
+ }
817
+
818
+ return null; // Head renders nothing in the component tree
819
+ }
820
+
821
+ // ─── createResource ──────────────────────────────────────
822
+ // Async data fetching primitive integrated with signals.
823
+ // Usage: const [data, { loading, error, refetch }] = createResource(fetcher)
824
+ // Usage with source: const [data, { loading, error, refetch }] = createResource(sourceSignal, fetcher)
825
+ // When source changes, fetcher is re-invoked automatically.
826
+
827
+ export function createResource(sourceOrFetcher, maybeFetcher) {
828
+ let source, fetcher;
829
+ if (typeof maybeFetcher === 'function') {
830
+ source = sourceOrFetcher;
831
+ fetcher = maybeFetcher;
832
+ } else {
833
+ source = null;
834
+ fetcher = sourceOrFetcher;
835
+ }
836
+
837
+ const [data, setData] = createSignal(undefined);
838
+ const [loading, setLoading] = createSignal(false);
839
+ const [error, setError] = createSignal(undefined);
840
+ let version = 0; // Guards against stale responses
841
+
842
+ function doFetch(sourceVal) {
843
+ const currentVersion = ++version;
844
+ setLoading(true);
845
+ setError(undefined);
846
+ try {
847
+ const result = source ? fetcher(sourceVal) : fetcher();
848
+ if (result && typeof result.then === 'function') {
849
+ result.then(
850
+ (val) => {
851
+ if (currentVersion === version) {
852
+ setData(() => val);
853
+ setLoading(false);
854
+ }
855
+ },
856
+ (err) => {
857
+ if (currentVersion === version) {
858
+ setError(() => err);
859
+ setLoading(false);
860
+ }
861
+ },
862
+ );
863
+ } else {
864
+ // Synchronous fetcher
865
+ if (currentVersion === version) {
866
+ setData(() => result);
867
+ setLoading(false);
868
+ }
869
+ }
870
+ } catch (err) {
871
+ if (currentVersion === version) {
872
+ setError(() => err);
873
+ setLoading(false);
874
+ }
875
+ }
876
+ }
877
+
878
+ function refetch() {
879
+ const sourceVal = source ? (typeof source === 'function' ? source() : source) : undefined;
880
+ doFetch(sourceVal);
881
+ }
882
+
883
+ // If source is provided, track it reactively
884
+ if (source) {
885
+ createEffect(() => {
886
+ const sourceVal = typeof source === 'function' ? source() : source;
887
+ if (sourceVal !== undefined && sourceVal !== null && sourceVal !== false) {
888
+ doFetch(sourceVal);
889
+ }
890
+ });
891
+ } else {
892
+ // Fetch immediately
893
+ doFetch();
894
+ }
895
+
896
+ return [data, { loading, error, refetch, mutate: setData }];
897
+ }
898
+
751
899
  // ─── DOM Rendering ────────────────────────────────────────
752
900
 
753
- // Inject scoped CSS into the page (idempotent only injects once per id)
754
- const __tovaInjectedStyles = new Set();
901
+ // CSP nonce set via configureCSP({ nonce: '...' }) or auto-detected from
902
+ // <meta name="csp-nonce" content="...">. Used for style tags to comply with
903
+ // Content-Security-Policy headers.
904
+ let __cspNonce = null;
905
+
906
+ export function configureCSP(options) {
907
+ if (options && options.nonce) __cspNonce = options.nonce;
908
+ }
909
+
910
+ function getCSPNonce() {
911
+ if (__cspNonce) return __cspNonce;
912
+ if (typeof document !== 'undefined' && typeof document.querySelector === 'function') {
913
+ const meta = document.querySelector('meta[name="csp-nonce"]');
914
+ if (meta) {
915
+ __cspNonce = meta.getAttribute('content');
916
+ return __cspNonce;
917
+ }
918
+ }
919
+ return null;
920
+ }
921
+
922
+ // Inject scoped CSS into the page with reference counting.
923
+ // Style tags are created on first use and removed when no component instances reference them.
924
+ // Supports CSP nonce for Content-Security-Policy compliance.
925
+ const __tovaStyleRefs = new Map(); // id → { el, count }
755
926
  export function tova_inject_css(id, css) {
756
- if (__tovaInjectedStyles.has(id)) return;
757
- __tovaInjectedStyles.add(id);
758
- const style = document.createElement('style');
759
- style.setAttribute('data-tova-style', id);
760
- style.textContent = css;
761
- document.head.appendChild(style);
927
+ const ref = __tovaStyleRefs.get(id);
928
+ if (ref) {
929
+ ref.count++;
930
+ } else {
931
+ const style = document.createElement('style');
932
+ style.setAttribute('data-tova-style', id);
933
+ const nonce = getCSPNonce();
934
+ if (nonce) style.setAttribute('nonce', nonce);
935
+ style.textContent = css;
936
+ document.head.appendChild(style);
937
+ __tovaStyleRefs.set(id, { el: style, count: 1 });
938
+ }
939
+ // Register cleanup on the current owner so unmount decrements the ref count
940
+ if (currentOwner) {
941
+ let cleaned = false;
942
+ const cleanup = () => {
943
+ if (cleaned) return;
944
+ cleaned = true;
945
+ const r = __tovaStyleRefs.get(id);
946
+ if (r) {
947
+ r.count--;
948
+ if (r.count <= 0) {
949
+ if (typeof r.el.remove === 'function') {
950
+ r.el.remove();
951
+ } else if (r.el.parentNode && typeof r.el.parentNode.removeChild === 'function') {
952
+ r.el.parentNode.removeChild(r.el);
953
+ }
954
+ __tovaStyleRefs.delete(id);
955
+ }
956
+ }
957
+ };
958
+ if (!currentOwner._cleanups) currentOwner._cleanups = [];
959
+ currentOwner._cleanups.push(cleanup);
960
+ }
762
961
  }
763
962
 
764
963
  export function tova_el(tag, props = {}, children = []) {
@@ -842,6 +1041,31 @@ export function tova_transition(vnode, nameOrConfig, config = {}) {
842
1041
  return vnode;
843
1042
  }
844
1043
 
1044
+ // ─── TransitionGroup ──────────────────────────────────────
1045
+ // Animates enter, leave, and move for keyed list items.
1046
+ // Usage: TransitionGroup({ name: "fade", tag: "ul", children: items.map(i => ...) })
1047
+ // Each child MUST have a `key` prop.
1048
+ // Supports FLIP-based move animations when items reorder.
1049
+
1050
+ export function TransitionGroup({ name = 'fade', tag = 'div', config = {}, children, ...rest }) {
1051
+ const transName = name;
1052
+ const transConfig = config;
1053
+ const childList = Array.isArray(children) ? children : (children ? [children] : []);
1054
+
1055
+ // Annotate each child vnode with the transition
1056
+ const annotated = childList.map(child => {
1057
+ if (child && child.__tova && !child._transition) {
1058
+ child._transition = { name: transName, config: transConfig };
1059
+ }
1060
+ return child;
1061
+ });
1062
+
1063
+ // Wrap in a container element (default <div>)
1064
+ const wrapper = tova_el(tag, { ...rest, 'data-tova-transition-group': '' }, annotated);
1065
+ wrapper._transitionGroup = { name: transName, config: transConfig };
1066
+ return wrapper;
1067
+ }
1068
+
845
1069
  // ─── Actions ──────────────────────────────────────────────
846
1070
  // use: directive support. Calls actionFn(el, param) after render.
847
1071
  // Returns the wrapped vnode. The action lifecycle (update/destroy) is managed.
@@ -1202,16 +1426,30 @@ export function render(vnode) {
1202
1426
  if (vnode.tag === '__portal') {
1203
1427
  const placeholder = document.createComment('portal');
1204
1428
  const targetSelector = vnode.props.target;
1429
+ const portalNodes = [];
1205
1430
  queueMicrotask(() => {
1206
1431
  const targetEl = typeof targetSelector === 'string'
1207
1432
  ? document.querySelector(targetSelector)
1208
1433
  : targetSelector;
1209
1434
  if (targetEl) {
1210
1435
  for (const child of flattenVNodes(vnode.children)) {
1211
- targetEl.appendChild(render(child));
1436
+ const rendered = render(child);
1437
+ targetEl.appendChild(rendered);
1438
+ portalNodes.push(rendered);
1212
1439
  }
1213
1440
  }
1214
1441
  });
1442
+ // Register cleanup: remove portal children when component unmounts
1443
+ if (currentOwner && !currentOwner._disposed) {
1444
+ if (!currentOwner._cleanups) currentOwner._cleanups = [];
1445
+ currentOwner._cleanups.push(() => {
1446
+ for (const node of portalNodes) {
1447
+ disposeNode(node);
1448
+ if (node.parentNode) node.parentNode.removeChild(node);
1449
+ }
1450
+ portalNodes.length = 0;
1451
+ });
1452
+ }
1215
1453
  return placeholder;
1216
1454
  }
1217
1455
 
@@ -1306,12 +1544,18 @@ function applyReactiveProps(el, props) {
1306
1544
  function applyPropValue(el, key, val) {
1307
1545
  if (key === 'className') {
1308
1546
  if (el.className !== val) el.className = val || '';
1309
- } else if (key === 'innerHTML' || key === 'dangerouslySetInnerHTML') {
1310
- const html = typeof val === 'object' && val !== null ? val.__html || '' : val || '';
1547
+ } else if (key === 'dangerouslySetInnerHTML') {
1548
+ // Explicit unsafe HTML injection requires {__html: "..."} format
1549
+ const html = typeof val === 'object' && val !== null ? val.__html || '' : '';
1311
1550
  if (__DEV__ && html) {
1312
- console.warn('Tova: Setting innerHTML can expose your app to XSS attacks. Ensure the content is sanitized.');
1551
+ console.warn('Tova: dangerouslySetInnerHTML bypasses XSS protection. Ensure content is sanitized.');
1313
1552
  }
1314
1553
  if (el.innerHTML !== html) el.innerHTML = html;
1554
+ } else if (key === 'innerHTML') {
1555
+ // Blocked: use dangerouslySetInnerHTML instead
1556
+ if (__DEV__) {
1557
+ console.error('Tova: innerHTML is not allowed. Use dangerouslySetInnerHTML={{__html: value}} to acknowledge XSS risk.');
1558
+ }
1315
1559
  } else if (key === 'value') {
1316
1560
  if (el !== document.activeElement && el.value !== val) {
1317
1561
  el.value = val;
@@ -2011,7 +2255,11 @@ export function mount(component, container) {
2011
2255
 
2012
2256
  const result = createRoot((dispose) => {
2013
2257
  const vnode = typeof component === 'function' ? component() : component;
2014
- container.innerHTML = '';
2258
+ if (typeof container.replaceChildren === 'function') {
2259
+ container.replaceChildren();
2260
+ } else {
2261
+ while (container.firstChild) container.removeChild(container.firstChild);
2262
+ }
2015
2263
  container.appendChild(render(vnode));
2016
2264
  return dispose;
2017
2265
  });
@@ -2054,3 +2302,137 @@ export function hydrateWhenVisible(component, domNode, options = {}) {
2054
2302
  observer.disconnect();
2055
2303
  };
2056
2304
  }
2305
+
2306
+ // ─── Form Handling ──────────────────────────────────────────
2307
+ // Reactive form primitives with field-level validation.
2308
+ // Usage:
2309
+ // const form = createForm({
2310
+ // fields: { email: { initial: '', validate: (v) => v.includes('@') ? null : 'Invalid email' } },
2311
+ // onSubmit: async (values) => { await server.register(values); }
2312
+ // });
2313
+ // <input bind:value={form.field('email').value} />
2314
+ // {form.field('email').error()}
2315
+ // <button on:click={form.submit} disabled={form.submitting()}>Submit</button>
2316
+
2317
+ export function createForm({ fields = {}, onSubmit, validateOnChange = true, validateOnBlur = true }) {
2318
+ const fieldSignals = {};
2319
+ const errorSignals = {};
2320
+ const touchedSignals = {};
2321
+ const [submitting, setSubmitting] = createSignal(false);
2322
+ const [submitError, setSubmitError] = createSignal(null);
2323
+ const [submitCount, setSubmitCount] = createSignal(0);
2324
+
2325
+ // Initialize field signals
2326
+ for (const [name, config] of Object.entries(fields)) {
2327
+ const initial = config.initial !== undefined ? config.initial : '';
2328
+ const [value, setValue] = createSignal(initial);
2329
+ const [error, setError] = createSignal(null);
2330
+ const [touched, setTouched] = createSignal(false);
2331
+ fieldSignals[name] = { value, setValue, validate: config.validate || null, initial };
2332
+ errorSignals[name] = { error, setError };
2333
+ touchedSignals[name] = { touched, setTouched };
2334
+ }
2335
+
2336
+ function validateField(name) {
2337
+ const f = fieldSignals[name];
2338
+ const e = errorSignals[name];
2339
+ if (!f || !e || !f.validate) return null;
2340
+ const err = f.validate(f.value());
2341
+ e.setError(err);
2342
+ return err;
2343
+ }
2344
+
2345
+ function validateAll() {
2346
+ let hasErrors = false;
2347
+ for (const name of Object.keys(fieldSignals)) {
2348
+ const err = validateField(name);
2349
+ if (err) hasErrors = true;
2350
+ }
2351
+ return !hasErrors;
2352
+ }
2353
+
2354
+ function field(name) {
2355
+ const f = fieldSignals[name];
2356
+ const e = errorSignals[name];
2357
+ const t = touchedSignals[name];
2358
+ if (!f) throw new Error(`Tova form: unknown field "${name}"`);
2359
+ return {
2360
+ value: f.value,
2361
+ error: e.error,
2362
+ touched: t.touched,
2363
+ set(val) {
2364
+ f.setValue(val);
2365
+ if (validateOnChange && t.touched()) validateField(name);
2366
+ },
2367
+ blur() {
2368
+ t.setTouched(true);
2369
+ if (validateOnBlur) validateField(name);
2370
+ },
2371
+ validate() { return validateField(name); },
2372
+ };
2373
+ }
2374
+
2375
+ function values() {
2376
+ const result = {};
2377
+ for (const [name, f] of Object.entries(fieldSignals)) {
2378
+ result[name] = f.value();
2379
+ }
2380
+ return result;
2381
+ }
2382
+
2383
+ function reset() {
2384
+ for (const [name, f] of Object.entries(fieldSignals)) {
2385
+ f.setValue(f.initial);
2386
+ errorSignals[name].setError(null);
2387
+ touchedSignals[name].setTouched(false);
2388
+ }
2389
+ setSubmitError(null);
2390
+ }
2391
+
2392
+ async function submit(e) {
2393
+ if (e && typeof e.preventDefault === 'function') e.preventDefault();
2394
+ // Touch all fields
2395
+ for (const name of Object.keys(touchedSignals)) {
2396
+ touchedSignals[name].setTouched(true);
2397
+ }
2398
+ if (!validateAll()) return;
2399
+ if (!onSubmit) return;
2400
+ setSubmitting(true);
2401
+ setSubmitError(null);
2402
+ setSubmitCount(c => c + 1);
2403
+ try {
2404
+ await onSubmit(values());
2405
+ } catch (err) {
2406
+ setSubmitError(err);
2407
+ } finally {
2408
+ setSubmitting(false);
2409
+ }
2410
+ }
2411
+
2412
+ const isValid = createComputed(() => {
2413
+ for (const name of Object.keys(errorSignals)) {
2414
+ if (errorSignals[name].error()) return false;
2415
+ }
2416
+ return true;
2417
+ });
2418
+
2419
+ const isDirty = createComputed(() => {
2420
+ for (const [name, f] of Object.entries(fieldSignals)) {
2421
+ if (f.value() !== f.initial) return true;
2422
+ }
2423
+ return false;
2424
+ });
2425
+
2426
+ return {
2427
+ field,
2428
+ values,
2429
+ reset,
2430
+ submit,
2431
+ submitting,
2432
+ submitError,
2433
+ submitCount,
2434
+ isValid,
2435
+ isDirty,
2436
+ validate: validateAll,
2437
+ };
2438
+ }