zero-query 0.7.5 → 0.8.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.
Files changed (65) hide show
  1. package/README.md +39 -30
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +127 -50
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +28 -3
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +377 -0
  20. package/cli/commands/dev/server.js +8 -0
  21. package/cli/commands/dev/watcher.js +26 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +1 -1
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/app/components/home.js +137 -0
  27. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  28. package/cli/scaffold/{scripts → app}/store.js +6 -6
  29. package/cli/scaffold/assets/.gitkeep +0 -0
  30. package/cli/scaffold/{styles/styles.css → global.css} +3 -2
  31. package/cli/scaffold/index.html +11 -11
  32. package/dist/zquery.dist.zip +0 -0
  33. package/dist/zquery.js +740 -226
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -11
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +154 -139
  39. package/src/core.js +57 -11
  40. package/src/diff.js +256 -58
  41. package/src/expression.js +33 -3
  42. package/src/reactive.js +37 -5
  43. package/src/router.js +196 -7
  44. package/src/ssr.js +1 -1
  45. package/tests/component.test.js +582 -0
  46. package/tests/core.test.js +251 -0
  47. package/tests/diff.test.js +333 -2
  48. package/tests/expression.test.js +148 -0
  49. package/tests/http.test.js +108 -0
  50. package/tests/reactive.test.js +148 -0
  51. package/tests/router.test.js +317 -0
  52. package/tests/store.test.js +126 -0
  53. package/tests/utils.test.js +161 -2
  54. package/types/collection.d.ts +17 -2
  55. package/types/component.d.ts +10 -34
  56. package/types/misc.d.ts +13 -0
  57. package/types/router.d.ts +30 -1
  58. package/cli/commands/dev.old.js +0 -520
  59. package/cli/scaffold/scripts/components/home.js +0 -137
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  65. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "0.7.5",
3
+ "version": "0.8.7",
4
4
  "description": "Lightweight modern frontend library — jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -21,9 +21,10 @@
21
21
  "scripts": {
22
22
  "build": "node cli/index.js build",
23
23
  "dev": "node cli/index.js dev zquery-website",
24
+ "dev:bundle": "node cli/index.js dev zquery-website --bundle",
24
25
  "dev-lib": "node cli/index.js build --watch",
25
26
  "bundle": "node cli/index.js bundle",
26
- "bundle:app": "node cli/index.js bundle zquery-website --minimal",
27
+ "bundle:app": "node cli/index.js bundle zquery-website",
27
28
  "test": "vitest run",
28
29
  "test:watch": "vitest"
29
30
  },
package/src/component.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * - Scoped styles (inline or via styleUrl)
16
16
  * - External templates via templateUrl (with {{expression}} interpolation)
17
17
  * - External styles via styleUrl (fetched & scoped automatically)
18
- * - Relative path resolution — templateUrl, styleUrl, and pages.dir
18
+ * - Relative path resolution — templateUrl and styleUrl
19
19
  * resolve relative to the component file automatically
20
20
  */
21
21
 
@@ -86,16 +86,6 @@ function _fetchResource(url) {
86
86
  return promise;
87
87
  }
88
88
 
89
- /**
90
- * Convert a kebab-case id to Title Case.
91
- * 'getting-started' → 'Getting Started'
92
- * @param {string} id
93
- * @returns {string}
94
- */
95
- function _titleCase(id) {
96
- return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
97
- }
98
-
99
89
  /**
100
90
  * Resolve a relative URL against a base.
101
91
  *
@@ -314,47 +304,10 @@ class Component {
314
304
  // - string → single stylesheet
315
305
  // - string[] → array of URLs → all fetched & concatenated
316
306
  //
317
- // pages config (shorthand for multi-template + route-param page switching):
318
- // pages: {
319
- // dir: 'pages', // relative to component file (or base)
320
- // param: 'section', // route param name → this.activePage
321
- // default: 'getting-started', // fallback when param is absent
322
- // ext: '.html', // file extension (default '.html')
323
- // items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
324
- // }
325
- // Exposes this.pages (array of {id,label}), this.activePage (current id)
326
- // Pages are lazy-loaded: only the active page is fetched on first render,
327
- // remaining pages are prefetched in the background for instant navigation.
328
- //
329
307
  async _loadExternals() {
330
308
  const def = this._def;
331
309
  const base = def._base; // auto-detected or explicit
332
310
 
333
- // -- Pages config ---------------------------------------------
334
- if (def.pages && !def._pagesNormalized) {
335
- const p = def.pages;
336
- const ext = p.ext || '.html';
337
- const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
338
-
339
- // Normalize items → [{id, label}, …]
340
- def._pages = (p.items || []).map(item => {
341
- if (typeof item === 'string') return { id: item, label: _titleCase(item) };
342
- return { id: item.id, label: item.label || _titleCase(item.id) };
343
- });
344
-
345
- // Build URL map for lazy per-page loading.
346
- // Pages are fetched on demand (active page first, rest prefetched in
347
- // the background) so the component renders as soon as the visible
348
- // page is ready instead of waiting for every page to download.
349
- def._pageUrls = {};
350
- for (const { id } of def._pages) {
351
- def._pageUrls[id] = `${dir}/${id}${ext}`;
352
- }
353
- if (!def._externalTemplates) def._externalTemplates = {};
354
-
355
- def._pagesNormalized = true;
356
- }
357
-
358
311
  // -- External templates --------------------------------------
359
312
  if (def.templateUrl && !def._templateLoaded) {
360
313
  const tu = def.templateUrl;
@@ -367,9 +320,8 @@ class Component {
367
320
  results.forEach((html, i) => { def._externalTemplates[i] = html; });
368
321
  } else if (typeof tu === 'object') {
369
322
  const entries = Object.entries(tu);
370
- // Pages config already resolved; plain objects still need resolving
371
323
  const results = await Promise.all(
372
- entries.map(([, url]) => _fetchResource(def._pagesNormalized ? url : _resolveUrl(url, base)))
324
+ entries.map(([, url]) => _fetchResource(_resolveUrl(url, base)))
373
325
  );
374
326
  def._externalTemplates = {};
375
327
  entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
@@ -398,8 +350,7 @@ class Component {
398
350
  _render() {
399
351
  // If externals haven't loaded yet, trigger async load then re-render
400
352
  if ((this._def.templateUrl && !this._def._templateLoaded) ||
401
- (this._def.styleUrl && !this._def._styleLoaded) ||
402
- (this._def.pages && !this._def._pagesNormalized)) {
353
+ (this._def.styleUrl && !this._def._styleLoaded)) {
403
354
  this._loadExternals().then(() => {
404
355
  if (!this._destroyed) this._render();
405
356
  });
@@ -411,43 +362,6 @@ class Component {
411
362
  this.templates = this._def._externalTemplates;
412
363
  }
413
364
 
414
- // Expose pages metadata and active page (derived from route param)
415
- if (this._def._pages) {
416
- this.pages = this._def._pages;
417
- const pc = this._def.pages;
418
- let active = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
419
-
420
- // Fall back to default if the param doesn't match any known page
421
- if (this._def._pageUrls && !(active in this._def._pageUrls)) {
422
- active = pc.default || this._def._pages[0]?.id || '';
423
- }
424
- this.activePage = active;
425
-
426
- // Lazy-load: fetch only the active page's template on demand
427
- if (this._def._pageUrls && !(active in this._def._externalTemplates)) {
428
- const url = this._def._pageUrls[active];
429
- if (url) {
430
- _fetchResource(url).then(html => {
431
- this._def._externalTemplates[active] = html;
432
- if (!this._destroyed) this._render();
433
- });
434
- return; // Wait for active page before rendering
435
- }
436
- }
437
-
438
- // Prefetch remaining pages in background (once, after active page is ready)
439
- if (this._def._pageUrls && !this._def._pagesPrefetched) {
440
- this._def._pagesPrefetched = true;
441
- for (const [id, url] of Object.entries(this._def._pageUrls)) {
442
- if (!(id in this._def._externalTemplates)) {
443
- _fetchResource(url).then(html => {
444
- this._def._externalTemplates[id] = html;
445
- });
446
- }
447
- }
448
- }
449
- }
450
-
451
365
  // Determine HTML content
452
366
  let html;
453
367
  if (this._def.render) {
@@ -472,6 +386,11 @@ class Component {
472
386
  html = '';
473
387
  }
474
388
 
389
+ // Pre-expand z-html and z-text at string level so the morph engine
390
+ // can diff their content properly (instead of clearing + re-injecting
391
+ // on every re-render). Same pattern as z-for: parse → evaluate → serialize.
392
+ html = this._expandContentDirectives(html);
393
+
475
394
  // -- Slot distribution ----------------------------------------
476
395
  // Replace <slot> elements with captured slot content from parent.
477
396
  // <slot> → default slot content
@@ -561,8 +480,10 @@ class Component {
561
480
 
562
481
  // Update DOM via morphing (diffing) — preserves unchanged nodes
563
482
  // First render uses innerHTML for speed; subsequent renders morph.
483
+ const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
564
484
  if (!this._mounted) {
565
485
  this._el.innerHTML = html;
486
+ if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
566
487
  } else {
567
488
  morph(this._el, html);
568
489
  }
@@ -605,31 +526,31 @@ class Component {
605
526
  }
606
527
  }
607
528
 
608
- // Bind @event="method" and z-on:event="method" handlers via delegation
529
+ // Bind @event="method" and z-on:event="method" handlers via delegation.
530
+ //
531
+ // Optimization: on the FIRST render, we scan for event attributes, build
532
+ // a delegated handler map, and attach one listener per event type to the
533
+ // component root. On subsequent renders (re-bind), we only rebuild the
534
+ // internal binding map — existing DOM listeners are reused since they
535
+ // delegate to event.target.closest(selector) at fire time.
609
536
  _bindEvents() {
610
- // Clean up old delegated listeners
611
- this._listeners.forEach(({ event, handler }) => {
612
- this._el.removeEventListener(event, handler);
613
- });
614
- this._listeners = [];
537
+ // Always rebuild the binding map from current DOM
538
+ const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
615
539
 
616
- // Find all elements with @event or z-on:event attributes
617
540
  const allEls = this._el.querySelectorAll('*');
618
- const eventMap = new Map(); // event → [{ selector, method, modifiers }]
619
-
620
541
  allEls.forEach(child => {
621
- // Skip elements inside z-pre subtrees
622
542
  if (child.closest('[z-pre]')) return;
623
543
 
624
- [...child.attributes].forEach(attr => {
625
- // Support both @event and z-on:event syntax
544
+ const attrs = child.attributes;
545
+ for (let a = 0; a < attrs.length; a++) {
546
+ const attr = attrs[a];
626
547
  let raw;
627
- if (attr.name.startsWith('@')) {
628
- raw = attr.name.slice(1); // @click.prevent → click.prevent
548
+ if (attr.name.charCodeAt(0) === 64) { // '@'
549
+ raw = attr.name.slice(1);
629
550
  } else if (attr.name.startsWith('z-on:')) {
630
- raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
551
+ raw = attr.name.slice(5);
631
552
  } else {
632
- return;
553
+ continue;
633
554
  }
634
555
 
635
556
  const parts = raw.split('.');
@@ -645,12 +566,45 @@ class Component {
645
566
 
646
567
  if (!eventMap.has(event)) eventMap.set(event, []);
647
568
  eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
648
- });
569
+ }
649
570
  });
650
571
 
572
+ // Store binding map for the delegated handlers to reference
573
+ this._eventBindings = eventMap;
574
+
575
+ // Only attach DOM listeners once — reuse on subsequent renders.
576
+ // The handlers close over `this` and read `this._eventBindings`
577
+ // at fire time, so they always use the latest binding map.
578
+ if (this._delegatedEvents) {
579
+ // Already attached — just make sure new event types are covered
580
+ for (const event of eventMap.keys()) {
581
+ if (!this._delegatedEvents.has(event)) {
582
+ this._attachDelegatedEvent(event, eventMap.get(event));
583
+ }
584
+ }
585
+ // Remove listeners for event types no longer in the template
586
+ for (const event of this._delegatedEvents.keys()) {
587
+ if (!eventMap.has(event)) {
588
+ const { handler, opts } = this._delegatedEvents.get(event);
589
+ this._el.removeEventListener(event, handler, opts);
590
+ this._delegatedEvents.delete(event);
591
+ // Also remove from _listeners array
592
+ this._listeners = this._listeners.filter(l => l.event !== event);
593
+ }
594
+ }
595
+ return;
596
+ }
597
+
598
+ this._delegatedEvents = new Map();
599
+
651
600
  // Register delegated listeners on the component root
652
601
  for (const [event, bindings] of eventMap) {
653
- // Determine listener options from modifiers
602
+ this._attachDelegatedEvent(event, bindings);
603
+ }
604
+ }
605
+
606
+ // Attach a single delegated listener for an event type
607
+ _attachDelegatedEvent(event, bindings) {
654
608
  const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
655
609
  const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
656
610
  const listenerOpts = (needsCapture || needsPassive)
@@ -658,7 +612,9 @@ class Component {
658
612
  : false;
659
613
 
660
614
  const handler = (e) => {
661
- for (const { selector, methodExpr, modifiers, el } of bindings) {
615
+ // Read bindings from live map always up to date after re-renders
616
+ const currentBindings = this._eventBindings?.get(event) || [];
617
+ for (const { selector, methodExpr, modifiers, el } of currentBindings) {
662
618
  if (!e.target.closest(selector)) continue;
663
619
 
664
620
  // .self — only fire if target is the element itself
@@ -728,7 +684,7 @@ class Component {
728
684
  };
729
685
  this._el.addEventListener(event, handler, listenerOpts);
730
686
  this._listeners.push({ event, handler });
731
- }
687
+ this._delegatedEvents.set(event, { handler, opts: listenerOpts });
732
688
  }
733
689
 
734
690
  // Bind z-ref="name" → this.refs.name
@@ -767,7 +723,7 @@ class Component {
767
723
  // Read current state value (supports dot-path keys)
768
724
  const currentVal = _getPath(this.state, key);
769
725
 
770
- // -- Set initial DOM value from state ------------------------
726
+ // -- Set initial DOM value from state (always sync) ----------
771
727
  if (tag === 'input' && type === 'checkbox') {
772
728
  el.checked = !!currentVal;
773
729
  } else if (tag === 'input' && type === 'radio') {
@@ -789,6 +745,11 @@ class Component {
789
745
  : isEditable ? 'input' : 'input';
790
746
 
791
747
  // -- Handler: read DOM → write to reactive state -------------
748
+ // Skip if already bound (morph preserves existing elements,
749
+ // so re-binding would stack duplicate listeners)
750
+ if (el._zqModelBound) return;
751
+ el._zqModelBound = true;
752
+
792
753
  const handler = () => {
793
754
  let val;
794
755
  if (type === 'checkbox') val = el.checked;
@@ -908,6 +869,41 @@ class Component {
908
869
  return temp.innerHTML;
909
870
  }
910
871
 
872
+ // ---------------------------------------------------------------------------
873
+ // _expandContentDirectives — Pre-morph z-html & z-text expansion
874
+ //
875
+ // Evaluates z-html and z-text directives at the string level so the morph
876
+ // engine receives HTML with the actual content inline. This lets the diff
877
+ // algorithm properly compare old vs new content (text nodes, child elements)
878
+ // instead of clearing + re-injecting on every re-render.
879
+ //
880
+ // Same parse → evaluate → serialize pattern as _expandZFor.
881
+ // ---------------------------------------------------------------------------
882
+ _expandContentDirectives(html) {
883
+ if (!html.includes('z-html') && !html.includes('z-text')) return html;
884
+
885
+ const temp = document.createElement('div');
886
+ temp.innerHTML = html;
887
+
888
+ // z-html: evaluate expression → inject as innerHTML
889
+ temp.querySelectorAll('[z-html]').forEach(el => {
890
+ if (el.closest('[z-pre]')) return;
891
+ const val = this._evalExpr(el.getAttribute('z-html'));
892
+ el.innerHTML = val != null ? String(val) : '';
893
+ el.removeAttribute('z-html');
894
+ });
895
+
896
+ // z-text: evaluate expression → inject as textContent (HTML-safe)
897
+ temp.querySelectorAll('[z-text]').forEach(el => {
898
+ if (el.closest('[z-pre]')) return;
899
+ const val = this._evalExpr(el.getAttribute('z-text'));
900
+ el.textContent = val != null ? String(val) : '';
901
+ el.removeAttribute('z-text');
902
+ });
903
+
904
+ return temp.innerHTML;
905
+ }
906
+
911
907
  // ---------------------------------------------------------------------------
912
908
  // _processDirectives — Post-innerHTML DOM-level directive processing
913
909
  // ---------------------------------------------------------------------------
@@ -960,25 +956,36 @@ class Component {
960
956
  });
961
957
 
962
958
  // -- z-bind:attr / :attr (dynamic attribute binding) -----------
963
- this._el.querySelectorAll('*').forEach(el => {
964
- if (el.closest('[z-pre]')) return;
965
- [...el.attributes].forEach(attr => {
959
+ // Use TreeWalker instead of querySelectorAll('*') avoids
960
+ // creating a flat array of every single descendant element.
961
+ // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
962
+ // at the walker level (faster than per-node closest('[z-pre]') checks).
963
+ const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
964
+ acceptNode(n) {
965
+ return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
966
+ }
967
+ });
968
+ let node;
969
+ while ((node = walker.nextNode())) {
970
+ const attrs = node.attributes;
971
+ for (let i = attrs.length - 1; i >= 0; i--) {
972
+ const attr = attrs[i];
966
973
  let attrName;
967
974
  if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
968
- else if (attr.name.startsWith(':') && !attr.name.startsWith('::')) attrName = attr.name.slice(1);
969
- else return;
975
+ else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
976
+ else continue;
970
977
 
971
978
  const val = this._evalExpr(attr.value);
972
- el.removeAttribute(attr.name);
979
+ node.removeAttribute(attr.name);
973
980
  if (val === false || val === null || val === undefined) {
974
- el.removeAttribute(attrName);
981
+ node.removeAttribute(attrName);
975
982
  } else if (val === true) {
976
- el.setAttribute(attrName, '');
983
+ node.setAttribute(attrName, '');
977
984
  } else {
978
- el.setAttribute(attrName, String(val));
985
+ node.setAttribute(attrName, String(val));
979
986
  }
980
- });
981
- });
987
+ }
988
+ }
982
989
 
983
990
  // -- z-class (dynamic class binding) ---------------------------
984
991
  this._el.querySelectorAll('[z-class]').forEach(el => {
@@ -1010,21 +1017,9 @@ class Component {
1010
1017
  el.removeAttribute('z-style');
1011
1018
  });
1012
1019
 
1013
- // -- z-html (innerHTML injection) ------------------------------
1014
- this._el.querySelectorAll('[z-html]').forEach(el => {
1015
- if (el.closest('[z-pre]')) return;
1016
- const val = this._evalExpr(el.getAttribute('z-html'));
1017
- el.innerHTML = val != null ? String(val) : '';
1018
- el.removeAttribute('z-html');
1019
- });
1020
-
1021
- // -- z-text (safe textContent binding) -------------------------
1022
- this._el.querySelectorAll('[z-text]').forEach(el => {
1023
- if (el.closest('[z-pre]')) return;
1024
- const val = this._evalExpr(el.getAttribute('z-text'));
1025
- el.textContent = val != null ? String(val) : '';
1026
- el.removeAttribute('z-text');
1027
- });
1020
+ // z-html and z-text are now pre-expanded at string level (before
1021
+ // morph) via _expandContentDirectives(), so the diff engine can
1022
+ // properly diff their content instead of clearing + re-injecting.
1028
1023
 
1029
1024
  // -- z-cloak (remove after render) -----------------------------
1030
1025
  this._el.querySelectorAll('[z-cloak]').forEach(el => {
@@ -1057,6 +1052,8 @@ class Component {
1057
1052
  }
1058
1053
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1059
1054
  this._listeners = [];
1055
+ this._delegatedEvents = null;
1056
+ this._eventBindings = null;
1060
1057
  if (this._styleEl) this._styleEl.remove();
1061
1058
  _instances.delete(this._el);
1062
1059
  this._el.innerHTML = '';
@@ -1067,7 +1064,7 @@ class Component {
1067
1064
  // Reserved definition keys (not user methods)
1068
1065
  const _reservedKeys = new Set([
1069
1066
  'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
1070
- 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage', 'base',
1067
+ 'templateUrl', 'styleUrl', 'templates', 'base',
1071
1068
  'computed', 'watch'
1072
1069
  ]);
1073
1070
 
@@ -1090,8 +1087,8 @@ export function component(name, definition) {
1090
1087
  }
1091
1088
  definition._name = name;
1092
1089
 
1093
- // Auto-detect the calling module's URL so that relative templateUrl,
1094
- // styleUrl, and pages.dir paths resolve relative to the component file.
1090
+ // Auto-detect the calling module's URL so that relative templateUrl
1091
+ // and styleUrl paths resolve relative to the component file.
1095
1092
  // An explicit `base` string on the definition overrides auto-detection.
1096
1093
  if (definition.base !== undefined) {
1097
1094
  definition._base = definition.base; // explicit override
@@ -1207,6 +1204,24 @@ export function getRegistry() {
1207
1204
  return Object.fromEntries(_registry);
1208
1205
  }
1209
1206
 
1207
+ /**
1208
+ * Pre-load a component's external templates and styles so the next mount
1209
+ * renders synchronously (no blank flash while fetching).
1210
+ * Safe to call multiple times — skips if already loaded.
1211
+ * @param {string} name — registered component name
1212
+ * @returns {Promise<void>}
1213
+ */
1214
+ export async function prefetch(name) {
1215
+ const def = _registry.get(name);
1216
+ if (!def) return;
1217
+
1218
+ // Load templateUrl and styleUrl if not already loaded.
1219
+ if ((def.templateUrl && !def._templateLoaded) ||
1220
+ (def.styleUrl && !def._styleLoaded)) {
1221
+ await Component.prototype._loadExternals.call({ _def: def });
1222
+ }
1223
+ }
1224
+
1210
1225
 
1211
1226
  // ---------------------------------------------------------------------------
1212
1227
  // Global stylesheet loader
package/src/core.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * into a full jQuery-like chainable wrapper with modern APIs.
6
6
  */
7
7
 
8
+ import { morph as _morph, morphElement as _morphElement } from './diff.js';
9
+
8
10
  // ---------------------------------------------------------------------------
9
11
  // ZQueryCollection — wraps an array of elements with chainable methods
10
12
  // ---------------------------------------------------------------------------
@@ -223,21 +225,46 @@ export class ZQueryCollection {
223
225
  // --- Classes -------------------------------------------------------------
224
226
 
225
227
  addClass(...names) {
228
+ // Fast path: single class, no spaces — avoids flatMap + regex split allocation
229
+ if (names.length === 1 && names[0].indexOf(' ') === -1) {
230
+ const c = names[0];
231
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
232
+ return this;
233
+ }
226
234
  const classes = names.flatMap(n => n.split(/\s+/));
227
- return this.each((_, el) => el.classList.add(...classes));
235
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
236
+ return this;
228
237
  }
229
238
 
230
239
  removeClass(...names) {
240
+ if (names.length === 1 && names[0].indexOf(' ') === -1) {
241
+ const c = names[0];
242
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
243
+ return this;
244
+ }
231
245
  const classes = names.flatMap(n => n.split(/\s+/));
232
- return this.each((_, el) => el.classList.remove(...classes));
246
+ for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
247
+ return this;
233
248
  }
234
249
 
235
250
  toggleClass(...args) {
236
251
  const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
252
+ // Fast path: single class, no spaces
253
+ if (args.length === 1 && args[0].indexOf(' ') === -1) {
254
+ const c = args[0];
255
+ for (let i = 0; i < this.elements.length; i++) {
256
+ force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
257
+ }
258
+ return this;
259
+ }
237
260
  const classes = args.flatMap(n => n.split(/\s+/));
238
- return this.each((_, el) => {
239
- classes.forEach(c => force !== undefined ? el.classList.toggle(c, force) : el.classList.toggle(c));
240
- });
261
+ for (let i = 0; i < this.elements.length; i++) {
262
+ const el = this.elements[i];
263
+ for (let j = 0; j < classes.length; j++) {
264
+ force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
265
+ }
266
+ }
267
+ return this;
241
268
  }
242
269
 
243
270
  hasClass(name) {
@@ -273,7 +300,8 @@ export class ZQueryCollection {
273
300
 
274
301
  css(props) {
275
302
  if (typeof props === 'string') {
276
- return getComputedStyle(this.first())[props];
303
+ const el = this.first();
304
+ return el ? getComputedStyle(el)[props] : undefined;
277
305
  }
278
306
  return this.each((_, el) => Object.assign(el.style, props));
279
307
  }
@@ -349,7 +377,21 @@ export class ZQueryCollection {
349
377
 
350
378
  html(content) {
351
379
  if (content === undefined) return this.first()?.innerHTML;
352
- return this.each((_, el) => { el.innerHTML = content; });
380
+ // Auto-morph: if the element already has children, use the diff engine
381
+ // to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
382
+ // Empty elements get raw innerHTML for fast first-paint — same strategy
383
+ // the component system uses (first render = innerHTML, updates = morph).
384
+ return this.each((_, el) => {
385
+ if (el.childNodes.length > 0) {
386
+ _morph(el, content);
387
+ } else {
388
+ el.innerHTML = content;
389
+ }
390
+ });
391
+ }
392
+
393
+ morph(content) {
394
+ return this.each((_, el) => { _morph(el, content); });
353
395
  }
354
396
 
355
397
  text(content) {
@@ -406,7 +448,8 @@ export class ZQueryCollection {
406
448
  }
407
449
 
408
450
  empty() {
409
- return this.each((_, el) => { el.innerHTML = ''; });
451
+ // textContent = '' clears all children without invoking the HTML parser
452
+ return this.each((_, el) => { el.textContent = ''; });
410
453
  }
411
454
 
412
455
  clone(deep = true) {
@@ -416,8 +459,9 @@ export class ZQueryCollection {
416
459
  replaceWith(content) {
417
460
  return this.each((_, el) => {
418
461
  if (typeof content === 'string') {
419
- el.insertAdjacentHTML('afterend', content);
420
- el.remove();
462
+ // Auto-morph: diff attributes + children when the tag name matches
463
+ // instead of destroying and re-creating the element.
464
+ _morphElement(el, content);
421
465
  } else if (content instanceof Node) {
422
466
  el.parentNode.replaceChild(content, el);
423
467
  }
@@ -503,7 +547,9 @@ export class ZQueryCollection {
503
547
 
504
548
  toggle(display = '') {
505
549
  return this.each((_, el) => {
506
- el.style.display = (el.style.display === 'none' || getComputedStyle(el).display === 'none') ? display : 'none';
550
+ // Check inline style first (cheap) before forcing layout via getComputedStyle
551
+ const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
552
+ el.style.display = hidden ? display : 'none';
507
553
  });
508
554
  }
509
555