zero-query 0.7.5 → 0.8.6

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 (64) hide show
  1. package/README.md +37 -27
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +107 -22
  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 +746 -134
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -9
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +161 -48
  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 +195 -6
  44. package/tests/component.test.js +582 -0
  45. package/tests/core.test.js +251 -0
  46. package/tests/diff.test.js +333 -2
  47. package/tests/expression.test.js +148 -0
  48. package/tests/http.test.js +108 -0
  49. package/tests/reactive.test.js +148 -0
  50. package/tests/router.test.js +317 -0
  51. package/tests/store.test.js +126 -0
  52. package/tests/utils.test.js +161 -2
  53. package/types/collection.d.ts +17 -2
  54. package/types/component.d.ts +7 -0
  55. package/types/misc.d.ts +13 -0
  56. package/types/router.d.ts +30 -1
  57. package/cli/commands/dev.old.js +0 -520
  58. package/cli/scaffold/scripts/components/home.js +0 -137
  59. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  64. /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.6",
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
@@ -339,7 +339,7 @@ class Component {
339
339
  // Normalize items → [{id, label}, …]
340
340
  def._pages = (p.items || []).map(item => {
341
341
  if (typeof item === 'string') return { id: item, label: _titleCase(item) };
342
- return { id: item.id, label: item.label || _titleCase(item.id) };
342
+ return { ...item, label: item.label || _titleCase(item.id) };
343
343
  });
344
344
 
345
345
  // Build URL map for lazy per-page loading.
@@ -472,6 +472,11 @@ class Component {
472
472
  html = '';
473
473
  }
474
474
 
475
+ // Pre-expand z-html and z-text at string level so the morph engine
476
+ // can diff their content properly (instead of clearing + re-injecting
477
+ // on every re-render). Same pattern as z-for: parse → evaluate → serialize.
478
+ html = this._expandContentDirectives(html);
479
+
475
480
  // -- Slot distribution ----------------------------------------
476
481
  // Replace <slot> elements with captured slot content from parent.
477
482
  // <slot> → default slot content
@@ -561,8 +566,10 @@ class Component {
561
566
 
562
567
  // Update DOM via morphing (diffing) — preserves unchanged nodes
563
568
  // First render uses innerHTML for speed; subsequent renders morph.
569
+ const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
564
570
  if (!this._mounted) {
565
571
  this._el.innerHTML = html;
572
+ if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
566
573
  } else {
567
574
  morph(this._el, html);
568
575
  }
@@ -605,31 +612,31 @@ class Component {
605
612
  }
606
613
  }
607
614
 
608
- // Bind @event="method" and z-on:event="method" handlers via delegation
615
+ // Bind @event="method" and z-on:event="method" handlers via delegation.
616
+ //
617
+ // Optimization: on the FIRST render, we scan for event attributes, build
618
+ // a delegated handler map, and attach one listener per event type to the
619
+ // component root. On subsequent renders (re-bind), we only rebuild the
620
+ // internal binding map — existing DOM listeners are reused since they
621
+ // delegate to event.target.closest(selector) at fire time.
609
622
  _bindEvents() {
610
- // Clean up old delegated listeners
611
- this._listeners.forEach(({ event, handler }) => {
612
- this._el.removeEventListener(event, handler);
613
- });
614
- this._listeners = [];
623
+ // Always rebuild the binding map from current DOM
624
+ const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
615
625
 
616
- // Find all elements with @event or z-on:event attributes
617
626
  const allEls = this._el.querySelectorAll('*');
618
- const eventMap = new Map(); // event → [{ selector, method, modifiers }]
619
-
620
627
  allEls.forEach(child => {
621
- // Skip elements inside z-pre subtrees
622
628
  if (child.closest('[z-pre]')) return;
623
629
 
624
- [...child.attributes].forEach(attr => {
625
- // Support both @event and z-on:event syntax
630
+ const attrs = child.attributes;
631
+ for (let a = 0; a < attrs.length; a++) {
632
+ const attr = attrs[a];
626
633
  let raw;
627
- if (attr.name.startsWith('@')) {
628
- raw = attr.name.slice(1); // @click.prevent → click.prevent
634
+ if (attr.name.charCodeAt(0) === 64) { // '@'
635
+ raw = attr.name.slice(1);
629
636
  } else if (attr.name.startsWith('z-on:')) {
630
- raw = attr.name.slice(5); // z-on:click.prevent → click.prevent
637
+ raw = attr.name.slice(5);
631
638
  } else {
632
- return;
639
+ continue;
633
640
  }
634
641
 
635
642
  const parts = raw.split('.');
@@ -645,12 +652,45 @@ class Component {
645
652
 
646
653
  if (!eventMap.has(event)) eventMap.set(event, []);
647
654
  eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
648
- });
655
+ }
649
656
  });
650
657
 
658
+ // Store binding map for the delegated handlers to reference
659
+ this._eventBindings = eventMap;
660
+
661
+ // Only attach DOM listeners once — reuse on subsequent renders.
662
+ // The handlers close over `this` and read `this._eventBindings`
663
+ // at fire time, so they always use the latest binding map.
664
+ if (this._delegatedEvents) {
665
+ // Already attached — just make sure new event types are covered
666
+ for (const event of eventMap.keys()) {
667
+ if (!this._delegatedEvents.has(event)) {
668
+ this._attachDelegatedEvent(event, eventMap.get(event));
669
+ }
670
+ }
671
+ // Remove listeners for event types no longer in the template
672
+ for (const event of this._delegatedEvents.keys()) {
673
+ if (!eventMap.has(event)) {
674
+ const { handler, opts } = this._delegatedEvents.get(event);
675
+ this._el.removeEventListener(event, handler, opts);
676
+ this._delegatedEvents.delete(event);
677
+ // Also remove from _listeners array
678
+ this._listeners = this._listeners.filter(l => l.event !== event);
679
+ }
680
+ }
681
+ return;
682
+ }
683
+
684
+ this._delegatedEvents = new Map();
685
+
651
686
  // Register delegated listeners on the component root
652
687
  for (const [event, bindings] of eventMap) {
653
- // Determine listener options from modifiers
688
+ this._attachDelegatedEvent(event, bindings);
689
+ }
690
+ }
691
+
692
+ // Attach a single delegated listener for an event type
693
+ _attachDelegatedEvent(event, bindings) {
654
694
  const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
655
695
  const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
656
696
  const listenerOpts = (needsCapture || needsPassive)
@@ -658,7 +698,9 @@ class Component {
658
698
  : false;
659
699
 
660
700
  const handler = (e) => {
661
- for (const { selector, methodExpr, modifiers, el } of bindings) {
701
+ // Read bindings from live map always up to date after re-renders
702
+ const currentBindings = this._eventBindings?.get(event) || [];
703
+ for (const { selector, methodExpr, modifiers, el } of currentBindings) {
662
704
  if (!e.target.closest(selector)) continue;
663
705
 
664
706
  // .self — only fire if target is the element itself
@@ -728,7 +770,7 @@ class Component {
728
770
  };
729
771
  this._el.addEventListener(event, handler, listenerOpts);
730
772
  this._listeners.push({ event, handler });
731
- }
773
+ this._delegatedEvents.set(event, { handler, opts: listenerOpts });
732
774
  }
733
775
 
734
776
  // Bind z-ref="name" → this.refs.name
@@ -767,7 +809,7 @@ class Component {
767
809
  // Read current state value (supports dot-path keys)
768
810
  const currentVal = _getPath(this.state, key);
769
811
 
770
- // -- Set initial DOM value from state ------------------------
812
+ // -- Set initial DOM value from state (always sync) ----------
771
813
  if (tag === 'input' && type === 'checkbox') {
772
814
  el.checked = !!currentVal;
773
815
  } else if (tag === 'input' && type === 'radio') {
@@ -789,6 +831,11 @@ class Component {
789
831
  : isEditable ? 'input' : 'input';
790
832
 
791
833
  // -- Handler: read DOM → write to reactive state -------------
834
+ // Skip if already bound (morph preserves existing elements,
835
+ // so re-binding would stack duplicate listeners)
836
+ if (el._zqModelBound) return;
837
+ el._zqModelBound = true;
838
+
792
839
  const handler = () => {
793
840
  let val;
794
841
  if (type === 'checkbox') val = el.checked;
@@ -908,6 +955,41 @@ class Component {
908
955
  return temp.innerHTML;
909
956
  }
910
957
 
958
+ // ---------------------------------------------------------------------------
959
+ // _expandContentDirectives — Pre-morph z-html & z-text expansion
960
+ //
961
+ // Evaluates z-html and z-text directives at the string level so the morph
962
+ // engine receives HTML with the actual content inline. This lets the diff
963
+ // algorithm properly compare old vs new content (text nodes, child elements)
964
+ // instead of clearing + re-injecting on every re-render.
965
+ //
966
+ // Same parse → evaluate → serialize pattern as _expandZFor.
967
+ // ---------------------------------------------------------------------------
968
+ _expandContentDirectives(html) {
969
+ if (!html.includes('z-html') && !html.includes('z-text')) return html;
970
+
971
+ const temp = document.createElement('div');
972
+ temp.innerHTML = html;
973
+
974
+ // z-html: evaluate expression → inject as innerHTML
975
+ temp.querySelectorAll('[z-html]').forEach(el => {
976
+ if (el.closest('[z-pre]')) return;
977
+ const val = this._evalExpr(el.getAttribute('z-html'));
978
+ el.innerHTML = val != null ? String(val) : '';
979
+ el.removeAttribute('z-html');
980
+ });
981
+
982
+ // z-text: evaluate expression → inject as textContent (HTML-safe)
983
+ temp.querySelectorAll('[z-text]').forEach(el => {
984
+ if (el.closest('[z-pre]')) return;
985
+ const val = this._evalExpr(el.getAttribute('z-text'));
986
+ el.textContent = val != null ? String(val) : '';
987
+ el.removeAttribute('z-text');
988
+ });
989
+
990
+ return temp.innerHTML;
991
+ }
992
+
911
993
  // ---------------------------------------------------------------------------
912
994
  // _processDirectives — Post-innerHTML DOM-level directive processing
913
995
  // ---------------------------------------------------------------------------
@@ -960,25 +1042,36 @@ class Component {
960
1042
  });
961
1043
 
962
1044
  // -- 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 => {
1045
+ // Use TreeWalker instead of querySelectorAll('*') avoids
1046
+ // creating a flat array of every single descendant element.
1047
+ // TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
1048
+ // at the walker level (faster than per-node closest('[z-pre]') checks).
1049
+ const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
1050
+ acceptNode(n) {
1051
+ return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
1052
+ }
1053
+ });
1054
+ let node;
1055
+ while ((node = walker.nextNode())) {
1056
+ const attrs = node.attributes;
1057
+ for (let i = attrs.length - 1; i >= 0; i--) {
1058
+ const attr = attrs[i];
966
1059
  let attrName;
967
1060
  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;
1061
+ else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
1062
+ else continue;
970
1063
 
971
1064
  const val = this._evalExpr(attr.value);
972
- el.removeAttribute(attr.name);
1065
+ node.removeAttribute(attr.name);
973
1066
  if (val === false || val === null || val === undefined) {
974
- el.removeAttribute(attrName);
1067
+ node.removeAttribute(attrName);
975
1068
  } else if (val === true) {
976
- el.setAttribute(attrName, '');
1069
+ node.setAttribute(attrName, '');
977
1070
  } else {
978
- el.setAttribute(attrName, String(val));
1071
+ node.setAttribute(attrName, String(val));
979
1072
  }
980
- });
981
- });
1073
+ }
1074
+ }
982
1075
 
983
1076
  // -- z-class (dynamic class binding) ---------------------------
984
1077
  this._el.querySelectorAll('[z-class]').forEach(el => {
@@ -1010,21 +1103,9 @@ class Component {
1010
1103
  el.removeAttribute('z-style');
1011
1104
  });
1012
1105
 
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
- });
1106
+ // z-html and z-text are now pre-expanded at string level (before
1107
+ // morph) via _expandContentDirectives(), so the diff engine can
1108
+ // properly diff their content instead of clearing + re-injecting.
1028
1109
 
1029
1110
  // -- z-cloak (remove after render) -----------------------------
1030
1111
  this._el.querySelectorAll('[z-cloak]').forEach(el => {
@@ -1057,6 +1138,8 @@ class Component {
1057
1138
  }
1058
1139
  this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
1059
1140
  this._listeners = [];
1141
+ this._delegatedEvents = null;
1142
+ this._eventBindings = null;
1060
1143
  if (this._styleEl) this._styleEl.remove();
1061
1144
  _instances.delete(this._el);
1062
1145
  this._el.innerHTML = '';
@@ -1207,6 +1290,36 @@ export function getRegistry() {
1207
1290
  return Object.fromEntries(_registry);
1208
1291
  }
1209
1292
 
1293
+ /**
1294
+ * Pre-load a component's external templates and styles so the next mount
1295
+ * renders synchronously (no blank flash while fetching).
1296
+ * Safe to call multiple times — skips if already loaded.
1297
+ * @param {string} name — registered component name
1298
+ * @returns {Promise<void>}
1299
+ */
1300
+ export async function prefetch(name) {
1301
+ const def = _registry.get(name);
1302
+ if (!def) return;
1303
+
1304
+ // Load templateUrl, styleUrl, and normalize pages config
1305
+ if ((def.templateUrl && !def._templateLoaded) ||
1306
+ (def.styleUrl && !def._styleLoaded) ||
1307
+ (def.pages && !def._pagesNormalized)) {
1308
+ await Component.prototype._loadExternals.call({ _def: def });
1309
+ }
1310
+
1311
+ // For pages-based components, prefetch ALL page templates so any
1312
+ // active page renders instantly on mount.
1313
+ if (def._pageUrls && def._externalTemplates) {
1314
+ const missing = Object.entries(def._pageUrls)
1315
+ .filter(([id]) => !(id in def._externalTemplates));
1316
+ if (missing.length) {
1317
+ const results = await Promise.all(missing.map(([, url]) => _fetchResource(url)));
1318
+ missing.forEach(([id], i) => { def._externalTemplates[id] = results[i]; });
1319
+ }
1320
+ }
1321
+ }
1322
+
1210
1323
 
1211
1324
  // ---------------------------------------------------------------------------
1212
1325
  // 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