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.
- package/README.md +37 -27
- package/cli/commands/build.js +110 -1
- package/cli/commands/bundle.js +107 -22
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +28 -3
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +377 -0
- package/cli/commands/dev/server.js +8 -0
- package/cli/commands/dev/watcher.js +26 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +1 -1
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +3 -2
- package/cli/scaffold/index.html +11 -11
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +746 -134
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +11 -9
- package/index.js +15 -10
- package/package.json +3 -2
- package/src/component.js +161 -48
- package/src/core.js +57 -11
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +195 -6
- package/tests/component.test.js +582 -0
- package/tests/core.test.js +251 -0
- package/tests/diff.test.js +333 -2
- package/tests/expression.test.js +148 -0
- package/tests/http.test.js +108 -0
- package/tests/reactive.test.js +148 -0
- package/tests/router.test.js +317 -0
- package/tests/store.test.js +126 -0
- package/tests/utils.test.js +161 -2
- package/types/collection.d.ts +17 -2
- package/types/component.d.ts +7 -0
- package/types/misc.d.ts +13 -0
- package/types/router.d.ts +30 -1
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/dist/zquery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery (zeroQuery) v0.
|
|
2
|
+
* zQuery (zeroQuery) v0.8.6
|
|
3
3
|
* Lightweight Frontend Library
|
|
4
4
|
* https://github.com/tonywied17/zero-query
|
|
5
5
|
* (c) 2026 Anthony Wiedman - MIT License
|
|
@@ -205,6 +205,8 @@ function reactive(target, onChange, _path = '') {
|
|
|
205
205
|
const old = obj[key];
|
|
206
206
|
if (old === value) return true;
|
|
207
207
|
obj[key] = value;
|
|
208
|
+
// Invalidate proxy cache for the old value (it may have been replaced)
|
|
209
|
+
if (old && typeof old === 'object') proxyCache.delete(old);
|
|
208
210
|
try {
|
|
209
211
|
onChange(key, value, old);
|
|
210
212
|
} catch (err) {
|
|
@@ -216,6 +218,7 @@ function reactive(target, onChange, _path = '') {
|
|
|
216
218
|
deleteProperty(obj, key) {
|
|
217
219
|
const old = obj[key];
|
|
218
220
|
delete obj[key];
|
|
221
|
+
if (old && typeof old === 'object') proxyCache.delete(old);
|
|
219
222
|
try {
|
|
220
223
|
onChange(key, undefined, old);
|
|
221
224
|
} catch (err) {
|
|
@@ -242,6 +245,10 @@ class Signal {
|
|
|
242
245
|
// Track dependency if there's an active effect
|
|
243
246
|
if (Signal._activeEffect) {
|
|
244
247
|
this._subscribers.add(Signal._activeEffect);
|
|
248
|
+
// Record this signal in the effect's dependency set for proper cleanup
|
|
249
|
+
if (Signal._activeEffect._deps) {
|
|
250
|
+
Signal._activeEffect._deps.add(this);
|
|
251
|
+
}
|
|
245
252
|
}
|
|
246
253
|
return this._value;
|
|
247
254
|
}
|
|
@@ -255,12 +262,15 @@ class Signal {
|
|
|
255
262
|
peek() { return this._value; }
|
|
256
263
|
|
|
257
264
|
_notify() {
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
// Snapshot subscribers before iterating — a subscriber might modify
|
|
266
|
+
// the set (e.g., an effect re-running, adding itself back)
|
|
267
|
+
const subs = [...this._subscribers];
|
|
268
|
+
for (let i = 0; i < subs.length; i++) {
|
|
269
|
+
try { subs[i](); }
|
|
260
270
|
catch (err) {
|
|
261
271
|
reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
|
|
262
272
|
}
|
|
263
|
-
}
|
|
273
|
+
}
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
subscribe(fn) {
|
|
@@ -295,12 +305,24 @@ function computed(fn) {
|
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
/**
|
|
298
|
-
* Create a side-effect that auto-tracks signal dependencies
|
|
308
|
+
* Create a side-effect that auto-tracks signal dependencies.
|
|
309
|
+
* Returns a dispose function that removes the effect from all
|
|
310
|
+
* signals it subscribed to — prevents memory leaks.
|
|
311
|
+
*
|
|
299
312
|
* @param {Function} fn — effect function
|
|
300
313
|
* @returns {Function} — dispose function
|
|
301
314
|
*/
|
|
302
315
|
function effect(fn) {
|
|
303
316
|
const execute = () => {
|
|
317
|
+
// Clean up old subscriptions before re-running so stale
|
|
318
|
+
// dependencies from a previous run are properly removed
|
|
319
|
+
if (execute._deps) {
|
|
320
|
+
for (const sig of execute._deps) {
|
|
321
|
+
sig._subscribers.delete(execute);
|
|
322
|
+
}
|
|
323
|
+
execute._deps.clear();
|
|
324
|
+
}
|
|
325
|
+
|
|
304
326
|
Signal._activeEffect = execute;
|
|
305
327
|
try { fn(); }
|
|
306
328
|
catch (err) {
|
|
@@ -308,9 +330,19 @@ function effect(fn) {
|
|
|
308
330
|
}
|
|
309
331
|
finally { Signal._activeEffect = null; }
|
|
310
332
|
};
|
|
333
|
+
|
|
334
|
+
// Track which signals this effect reads from
|
|
335
|
+
execute._deps = new Set();
|
|
336
|
+
|
|
311
337
|
execute();
|
|
312
338
|
return () => {
|
|
313
|
-
//
|
|
339
|
+
// Dispose: remove this effect from every signal it subscribed to
|
|
340
|
+
if (execute._deps) {
|
|
341
|
+
for (const sig of execute._deps) {
|
|
342
|
+
sig._subscribers.delete(execute);
|
|
343
|
+
}
|
|
344
|
+
execute._deps.clear();
|
|
345
|
+
}
|
|
314
346
|
Signal._activeEffect = null;
|
|
315
347
|
};
|
|
316
348
|
}
|
|
@@ -323,6 +355,7 @@ function effect(fn) {
|
|
|
323
355
|
* into a full jQuery-like chainable wrapper with modern APIs.
|
|
324
356
|
*/
|
|
325
357
|
|
|
358
|
+
|
|
326
359
|
// ---------------------------------------------------------------------------
|
|
327
360
|
// ZQueryCollection — wraps an array of elements with chainable methods
|
|
328
361
|
// ---------------------------------------------------------------------------
|
|
@@ -541,21 +574,46 @@ class ZQueryCollection {
|
|
|
541
574
|
// --- Classes -------------------------------------------------------------
|
|
542
575
|
|
|
543
576
|
addClass(...names) {
|
|
577
|
+
// Fast path: single class, no spaces — avoids flatMap + regex split allocation
|
|
578
|
+
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
579
|
+
const c = names[0];
|
|
580
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(c);
|
|
581
|
+
return this;
|
|
582
|
+
}
|
|
544
583
|
const classes = names.flatMap(n => n.split(/\s+/));
|
|
545
|
-
|
|
584
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.add(...classes);
|
|
585
|
+
return this;
|
|
546
586
|
}
|
|
547
587
|
|
|
548
588
|
removeClass(...names) {
|
|
589
|
+
if (names.length === 1 && names[0].indexOf(' ') === -1) {
|
|
590
|
+
const c = names[0];
|
|
591
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(c);
|
|
592
|
+
return this;
|
|
593
|
+
}
|
|
549
594
|
const classes = names.flatMap(n => n.split(/\s+/));
|
|
550
|
-
|
|
595
|
+
for (let i = 0; i < this.elements.length; i++) this.elements[i].classList.remove(...classes);
|
|
596
|
+
return this;
|
|
551
597
|
}
|
|
552
598
|
|
|
553
599
|
toggleClass(...args) {
|
|
554
600
|
const force = typeof args[args.length - 1] === 'boolean' ? args.pop() : undefined;
|
|
601
|
+
// Fast path: single class, no spaces
|
|
602
|
+
if (args.length === 1 && args[0].indexOf(' ') === -1) {
|
|
603
|
+
const c = args[0];
|
|
604
|
+
for (let i = 0; i < this.elements.length; i++) {
|
|
605
|
+
force !== undefined ? this.elements[i].classList.toggle(c, force) : this.elements[i].classList.toggle(c);
|
|
606
|
+
}
|
|
607
|
+
return this;
|
|
608
|
+
}
|
|
555
609
|
const classes = args.flatMap(n => n.split(/\s+/));
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
610
|
+
for (let i = 0; i < this.elements.length; i++) {
|
|
611
|
+
const el = this.elements[i];
|
|
612
|
+
for (let j = 0; j < classes.length; j++) {
|
|
613
|
+
force !== undefined ? el.classList.toggle(classes[j], force) : el.classList.toggle(classes[j]);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return this;
|
|
559
617
|
}
|
|
560
618
|
|
|
561
619
|
hasClass(name) {
|
|
@@ -591,7 +649,8 @@ class ZQueryCollection {
|
|
|
591
649
|
|
|
592
650
|
css(props) {
|
|
593
651
|
if (typeof props === 'string') {
|
|
594
|
-
|
|
652
|
+
const el = this.first();
|
|
653
|
+
return el ? getComputedStyle(el)[props] : undefined;
|
|
595
654
|
}
|
|
596
655
|
return this.each((_, el) => Object.assign(el.style, props));
|
|
597
656
|
}
|
|
@@ -667,7 +726,21 @@ class ZQueryCollection {
|
|
|
667
726
|
|
|
668
727
|
html(content) {
|
|
669
728
|
if (content === undefined) return this.first()?.innerHTML;
|
|
670
|
-
|
|
729
|
+
// Auto-morph: if the element already has children, use the diff engine
|
|
730
|
+
// to patch the DOM (preserves focus, scroll, state, keyed reorder via LIS).
|
|
731
|
+
// Empty elements get raw innerHTML for fast first-paint — same strategy
|
|
732
|
+
// the component system uses (first render = innerHTML, updates = morph).
|
|
733
|
+
return this.each((_, el) => {
|
|
734
|
+
if (el.childNodes.length > 0) {
|
|
735
|
+
_morph(el, content);
|
|
736
|
+
} else {
|
|
737
|
+
el.innerHTML = content;
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
morph(content) {
|
|
743
|
+
return this.each((_, el) => { _morph(el, content); });
|
|
671
744
|
}
|
|
672
745
|
|
|
673
746
|
text(content) {
|
|
@@ -724,7 +797,8 @@ class ZQueryCollection {
|
|
|
724
797
|
}
|
|
725
798
|
|
|
726
799
|
empty() {
|
|
727
|
-
|
|
800
|
+
// textContent = '' clears all children without invoking the HTML parser
|
|
801
|
+
return this.each((_, el) => { el.textContent = ''; });
|
|
728
802
|
}
|
|
729
803
|
|
|
730
804
|
clone(deep = true) {
|
|
@@ -734,8 +808,9 @@ class ZQueryCollection {
|
|
|
734
808
|
replaceWith(content) {
|
|
735
809
|
return this.each((_, el) => {
|
|
736
810
|
if (typeof content === 'string') {
|
|
737
|
-
|
|
738
|
-
|
|
811
|
+
// Auto-morph: diff attributes + children when the tag name matches
|
|
812
|
+
// instead of destroying and re-creating the element.
|
|
813
|
+
_morphElement(el, content);
|
|
739
814
|
} else if (content instanceof Node) {
|
|
740
815
|
el.parentNode.replaceChild(content, el);
|
|
741
816
|
}
|
|
@@ -821,7 +896,9 @@ class ZQueryCollection {
|
|
|
821
896
|
|
|
822
897
|
toggle(display = '') {
|
|
823
898
|
return this.each((_, el) => {
|
|
824
|
-
|
|
899
|
+
// Check inline style first (cheap) before forcing layout via getComputedStyle
|
|
900
|
+
const hidden = el.style.display === 'none' || (el.style.display !== '' ? false : getComputedStyle(el).display === 'none');
|
|
901
|
+
el.style.display = hidden ? display : 'none';
|
|
825
902
|
});
|
|
826
903
|
}
|
|
827
904
|
|
|
@@ -1932,13 +2009,43 @@ function _evalBinary(node, scope) {
|
|
|
1932
2009
|
* Typical: [loopVars, state, { props, refs, $ }]
|
|
1933
2010
|
* @returns {*} — evaluation result, or undefined on error
|
|
1934
2011
|
*/
|
|
2012
|
+
|
|
2013
|
+
// AST cache — avoids re-tokenizing and re-parsing the same expression string.
|
|
2014
|
+
// Bounded to prevent unbounded memory growth in long-lived apps.
|
|
2015
|
+
const _astCache = new Map();
|
|
2016
|
+
const _AST_CACHE_MAX = 512;
|
|
2017
|
+
|
|
1935
2018
|
function safeEval(expr, scope) {
|
|
1936
2019
|
try {
|
|
1937
2020
|
const trimmed = expr.trim();
|
|
1938
2021
|
if (!trimmed) return undefined;
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
2022
|
+
|
|
2023
|
+
// Fast path for simple identifiers: "count", "name", "visible"
|
|
2024
|
+
// Avoids full tokenize→parse→evaluate overhead for the most common case.
|
|
2025
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(trimmed)) {
|
|
2026
|
+
for (const layer of scope) {
|
|
2027
|
+
if (layer && typeof layer === 'object' && trimmed in layer) {
|
|
2028
|
+
return layer[trimmed];
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
// Fall through to full parser for built-in globals (Math, JSON, etc.)
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Check AST cache
|
|
2035
|
+
let ast = _astCache.get(trimmed);
|
|
2036
|
+
if (!ast) {
|
|
2037
|
+
const tokens = tokenize(trimmed);
|
|
2038
|
+
const parser = new Parser(tokens, scope);
|
|
2039
|
+
ast = parser.parse();
|
|
2040
|
+
|
|
2041
|
+
// Evict oldest entries when cache is full
|
|
2042
|
+
if (_astCache.size >= _AST_CACHE_MAX) {
|
|
2043
|
+
const first = _astCache.keys().next().value;
|
|
2044
|
+
_astCache.delete(first);
|
|
2045
|
+
}
|
|
2046
|
+
_astCache.set(trimmed, ast);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1942
2049
|
return evaluate(ast, scope);
|
|
1943
2050
|
} catch (err) {
|
|
1944
2051
|
if (typeof console !== 'undefined' && console.debug) {
|
|
@@ -1958,8 +2065,27 @@ function safeEval(expr, scope) {
|
|
|
1958
2065
|
*
|
|
1959
2066
|
* Approach: walk old and new trees in parallel, reconcile node by node.
|
|
1960
2067
|
* Keyed elements (via `z-key`) get matched across position changes.
|
|
2068
|
+
*
|
|
2069
|
+
* Performance advantages over virtual DOM (React/Angular):
|
|
2070
|
+
* - No virtual tree allocation or diffing — works directly on real DOM
|
|
2071
|
+
* - Skips unchanged subtrees via fast isEqualNode() check
|
|
2072
|
+
* - z-skip attribute to opt out of diffing entire subtrees
|
|
2073
|
+
* - Reuses a single template element for HTML parsing (zero GC pressure)
|
|
2074
|
+
* - Keyed reconciliation uses LIS (Longest Increasing Subsequence) to
|
|
2075
|
+
* minimize DOM moves — same algorithm as Vue 3 / ivi
|
|
2076
|
+
* - Minimal attribute diffing with early bail-out
|
|
1961
2077
|
*/
|
|
1962
2078
|
|
|
2079
|
+
// ---------------------------------------------------------------------------
|
|
2080
|
+
// Reusable template element — avoids per-call allocation
|
|
2081
|
+
// ---------------------------------------------------------------------------
|
|
2082
|
+
let _tpl = null;
|
|
2083
|
+
|
|
2084
|
+
function _getTemplate() {
|
|
2085
|
+
if (!_tpl) _tpl = document.createElement('template');
|
|
2086
|
+
return _tpl;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1963
2089
|
// ---------------------------------------------------------------------------
|
|
1964
2090
|
// morph(existingRoot, newHTML) — patch existing DOM to match newHTML
|
|
1965
2091
|
// ---------------------------------------------------------------------------
|
|
@@ -1972,15 +2098,53 @@ function safeEval(expr, scope) {
|
|
|
1972
2098
|
* @param {string} newHTML — The desired HTML string
|
|
1973
2099
|
*/
|
|
1974
2100
|
function morph(rootEl, newHTML) {
|
|
1975
|
-
const
|
|
1976
|
-
|
|
1977
|
-
|
|
2101
|
+
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
2102
|
+
const tpl = _getTemplate();
|
|
2103
|
+
tpl.innerHTML = newHTML;
|
|
2104
|
+
const newRoot = tpl.content;
|
|
1978
2105
|
|
|
1979
|
-
//
|
|
2106
|
+
// Move children into a wrapper for consistent handling.
|
|
2107
|
+
// We move (not clone) from the template — cheaper than cloning.
|
|
1980
2108
|
const tempDiv = document.createElement('div');
|
|
1981
2109
|
while (newRoot.firstChild) tempDiv.appendChild(newRoot.firstChild);
|
|
1982
2110
|
|
|
1983
2111
|
_morphChildren(rootEl, tempDiv);
|
|
2112
|
+
|
|
2113
|
+
if (start) window.__zqMorphHook(rootEl, performance.now() - start);
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* Morph a single element in place — diffs attributes and children
|
|
2118
|
+
* without replacing the node reference. Useful for replaceWith-style
|
|
2119
|
+
* updates where you want to keep the element identity when the tag
|
|
2120
|
+
* name matches.
|
|
2121
|
+
*
|
|
2122
|
+
* If the new HTML produces a different tag, falls back to native replace.
|
|
2123
|
+
*
|
|
2124
|
+
* @param {Element} oldEl — The live DOM element to patch
|
|
2125
|
+
* @param {string} newHTML — HTML string for the replacement element
|
|
2126
|
+
* @returns {Element} — The resulting element (same ref if morphed, new if replaced)
|
|
2127
|
+
*/
|
|
2128
|
+
function morphElement(oldEl, newHTML) {
|
|
2129
|
+
const start = typeof window !== 'undefined' && window.__zqMorphHook ? performance.now() : 0;
|
|
2130
|
+
const tpl = _getTemplate();
|
|
2131
|
+
tpl.innerHTML = newHTML;
|
|
2132
|
+
const newEl = tpl.content.firstElementChild;
|
|
2133
|
+
if (!newEl) return oldEl;
|
|
2134
|
+
|
|
2135
|
+
// Same tag — morph in place (preserves identity, event listeners, refs)
|
|
2136
|
+
if (oldEl.nodeName === newEl.nodeName) {
|
|
2137
|
+
_morphAttributes(oldEl, newEl);
|
|
2138
|
+
_morphChildren(oldEl, newEl);
|
|
2139
|
+
if (start) window.__zqMorphHook(oldEl, performance.now() - start);
|
|
2140
|
+
return oldEl;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Different tag — must replace (can't morph <div> into <span>)
|
|
2144
|
+
const clone = newEl.cloneNode(true);
|
|
2145
|
+
oldEl.parentNode.replaceChild(clone, oldEl);
|
|
2146
|
+
if (start) window.__zqMorphHook(clone, performance.now() - start);
|
|
2147
|
+
return clone;
|
|
1984
2148
|
}
|
|
1985
2149
|
|
|
1986
2150
|
/**
|
|
@@ -1990,25 +2154,42 @@ function morph(rootEl, newHTML) {
|
|
|
1990
2154
|
* @param {Element} newParent — desired state parent
|
|
1991
2155
|
*/
|
|
1992
2156
|
function _morphChildren(oldParent, newParent) {
|
|
1993
|
-
|
|
1994
|
-
|
|
2157
|
+
// Snapshot live NodeLists into arrays — childNodes is live and
|
|
2158
|
+
// mutates during insertBefore/removeChild. Using a for loop to push
|
|
2159
|
+
// avoids spread operator overhead for large child lists.
|
|
2160
|
+
const oldCN = oldParent.childNodes;
|
|
2161
|
+
const newCN = newParent.childNodes;
|
|
2162
|
+
const oldLen = oldCN.length;
|
|
2163
|
+
const newLen = newCN.length;
|
|
2164
|
+
const oldChildren = new Array(oldLen);
|
|
2165
|
+
const newChildren = new Array(newLen);
|
|
2166
|
+
for (let i = 0; i < oldLen; i++) oldChildren[i] = oldCN[i];
|
|
2167
|
+
for (let i = 0; i < newLen; i++) newChildren[i] = newCN[i];
|
|
1995
2168
|
|
|
1996
|
-
//
|
|
1997
|
-
|
|
1998
|
-
|
|
2169
|
+
// Scan for keyed elements — only build maps if keys exist
|
|
2170
|
+
let hasKeys = false;
|
|
2171
|
+
let oldKeyMap, newKeyMap;
|
|
1999
2172
|
|
|
2000
|
-
for (let i = 0; i <
|
|
2001
|
-
|
|
2002
|
-
if (key != null) oldKeyMap.set(key, i);
|
|
2173
|
+
for (let i = 0; i < oldLen; i++) {
|
|
2174
|
+
if (_getKey(oldChildren[i]) != null) { hasKeys = true; break; }
|
|
2003
2175
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2176
|
+
if (!hasKeys) {
|
|
2177
|
+
for (let i = 0; i < newLen; i++) {
|
|
2178
|
+
if (_getKey(newChildren[i]) != null) { hasKeys = true; break; }
|
|
2179
|
+
}
|
|
2007
2180
|
}
|
|
2008
2181
|
|
|
2009
|
-
const hasKeys = oldKeyMap.size > 0 || newKeyMap.size > 0;
|
|
2010
|
-
|
|
2011
2182
|
if (hasKeys) {
|
|
2183
|
+
oldKeyMap = new Map();
|
|
2184
|
+
newKeyMap = new Map();
|
|
2185
|
+
for (let i = 0; i < oldLen; i++) {
|
|
2186
|
+
const key = _getKey(oldChildren[i]);
|
|
2187
|
+
if (key != null) oldKeyMap.set(key, i);
|
|
2188
|
+
}
|
|
2189
|
+
for (let i = 0; i < newLen; i++) {
|
|
2190
|
+
const key = _getKey(newChildren[i]);
|
|
2191
|
+
if (key != null) newKeyMap.set(key, i);
|
|
2192
|
+
}
|
|
2012
2193
|
_morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap);
|
|
2013
2194
|
} else {
|
|
2014
2195
|
_morphChildrenUnkeyed(oldParent, oldChildren, newChildren);
|
|
@@ -2019,35 +2200,42 @@ function _morphChildren(oldParent, newParent) {
|
|
|
2019
2200
|
* Unkeyed reconciliation — positional matching.
|
|
2020
2201
|
*/
|
|
2021
2202
|
function _morphChildrenUnkeyed(oldParent, oldChildren, newChildren) {
|
|
2022
|
-
const
|
|
2203
|
+
const oldLen = oldChildren.length;
|
|
2204
|
+
const newLen = newChildren.length;
|
|
2205
|
+
const minLen = oldLen < newLen ? oldLen : newLen;
|
|
2023
2206
|
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2207
|
+
// Morph overlapping range
|
|
2208
|
+
for (let i = 0; i < minLen; i++) {
|
|
2209
|
+
_morphNode(oldParent, oldChildren[i], newChildren[i]);
|
|
2210
|
+
}
|
|
2027
2211
|
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2212
|
+
// Append new nodes
|
|
2213
|
+
if (newLen > oldLen) {
|
|
2214
|
+
for (let i = oldLen; i < newLen; i++) {
|
|
2215
|
+
oldParent.appendChild(newChildren[i].cloneNode(true));
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// Remove excess old nodes (iterate backwards to avoid index shifting)
|
|
2220
|
+
if (oldLen > newLen) {
|
|
2221
|
+
for (let i = oldLen - 1; i >= newLen; i--) {
|
|
2222
|
+
oldParent.removeChild(oldChildren[i]);
|
|
2036
2223
|
}
|
|
2037
2224
|
}
|
|
2038
2225
|
}
|
|
2039
2226
|
|
|
2040
2227
|
/**
|
|
2041
|
-
* Keyed reconciliation — match by z-key, reorder minimal moves
|
|
2228
|
+
* Keyed reconciliation — match by z-key, reorder with minimal moves
|
|
2229
|
+
* using Longest Increasing Subsequence (LIS) to find the maximum set
|
|
2230
|
+
* of nodes that are already in the correct relative order, then only
|
|
2231
|
+
* move the remaining nodes.
|
|
2042
2232
|
*/
|
|
2043
2233
|
function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, newKeyMap) {
|
|
2044
|
-
// Track which old nodes are consumed
|
|
2045
2234
|
const consumed = new Set();
|
|
2046
|
-
|
|
2047
|
-
// Step 1: Build ordered list of matched old nodes for new children
|
|
2048
2235
|
const newLen = newChildren.length;
|
|
2049
|
-
const matched = new Array(newLen);
|
|
2236
|
+
const matched = new Array(newLen);
|
|
2050
2237
|
|
|
2238
|
+
// Step 1: Match new children to old children by key
|
|
2051
2239
|
for (let i = 0; i < newLen; i++) {
|
|
2052
2240
|
const key = _getKey(newChildren[i]);
|
|
2053
2241
|
if (key != null && oldKeyMap.has(key)) {
|
|
@@ -2059,21 +2247,40 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
2059
2247
|
}
|
|
2060
2248
|
}
|
|
2061
2249
|
|
|
2062
|
-
// Step 2: Remove old nodes
|
|
2250
|
+
// Step 2: Remove old keyed nodes not in the new tree
|
|
2063
2251
|
for (let i = oldChildren.length - 1; i >= 0; i--) {
|
|
2064
2252
|
if (!consumed.has(i)) {
|
|
2065
2253
|
const key = _getKey(oldChildren[i]);
|
|
2066
2254
|
if (key != null && !newKeyMap.has(key)) {
|
|
2067
2255
|
oldParent.removeChild(oldChildren[i]);
|
|
2068
|
-
} else if (key == null) {
|
|
2069
|
-
// Unkeyed old node — will be handled positionally below
|
|
2070
2256
|
}
|
|
2071
2257
|
}
|
|
2072
2258
|
}
|
|
2073
2259
|
|
|
2074
|
-
// Step 3:
|
|
2260
|
+
// Step 3: Build index array for LIS of matched old indices.
|
|
2261
|
+
// This finds the largest set of keyed nodes already in order,
|
|
2262
|
+
// so we only need to move the rest — O(n log n) instead of O(n²).
|
|
2263
|
+
const oldIndices = []; // Maps new-position → old-position (or -1)
|
|
2264
|
+
for (let i = 0; i < newLen; i++) {
|
|
2265
|
+
if (matched[i]) {
|
|
2266
|
+
const key = _getKey(newChildren[i]);
|
|
2267
|
+
oldIndices.push(oldKeyMap.get(key));
|
|
2268
|
+
} else {
|
|
2269
|
+
oldIndices.push(-1);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
const lisSet = _lis(oldIndices);
|
|
2274
|
+
|
|
2275
|
+
// Step 4: Insert / reorder / morph — walk new children forward,
|
|
2276
|
+
// using LIS to decide which nodes stay in place.
|
|
2075
2277
|
let cursor = oldParent.firstChild;
|
|
2076
|
-
const unkeyedOld =
|
|
2278
|
+
const unkeyedOld = [];
|
|
2279
|
+
for (let i = 0; i < oldChildren.length; i++) {
|
|
2280
|
+
if (!consumed.has(i) && _getKey(oldChildren[i]) == null) {
|
|
2281
|
+
unkeyedOld.push(oldChildren[i]);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2077
2284
|
let unkeyedIdx = 0;
|
|
2078
2285
|
|
|
2079
2286
|
for (let i = 0; i < newLen; i++) {
|
|
@@ -2082,16 +2289,14 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
2082
2289
|
let oldNode = matched[i];
|
|
2083
2290
|
|
|
2084
2291
|
if (!oldNode && newKey == null) {
|
|
2085
|
-
// Try to match an unkeyed old node positionally
|
|
2086
2292
|
oldNode = unkeyedOld[unkeyedIdx++] || null;
|
|
2087
2293
|
}
|
|
2088
2294
|
|
|
2089
2295
|
if (oldNode) {
|
|
2090
|
-
//
|
|
2091
|
-
if (
|
|
2296
|
+
// If this node is NOT part of the LIS, it needs to be moved
|
|
2297
|
+
if (!lisSet.has(i)) {
|
|
2092
2298
|
oldParent.insertBefore(oldNode, cursor);
|
|
2093
2299
|
}
|
|
2094
|
-
// Morph in place
|
|
2095
2300
|
_morphNode(oldParent, oldNode, newNode);
|
|
2096
2301
|
cursor = oldNode.nextSibling;
|
|
2097
2302
|
} else {
|
|
@@ -2102,11 +2307,10 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
2102
2307
|
} else {
|
|
2103
2308
|
oldParent.appendChild(clone);
|
|
2104
2309
|
}
|
|
2105
|
-
// cursor stays the same — new node is before it
|
|
2106
2310
|
}
|
|
2107
2311
|
}
|
|
2108
2312
|
|
|
2109
|
-
// Remove
|
|
2313
|
+
// Remove remaining unkeyed old nodes
|
|
2110
2314
|
while (unkeyedIdx < unkeyedOld.length) {
|
|
2111
2315
|
const leftover = unkeyedOld[unkeyedIdx++];
|
|
2112
2316
|
if (leftover.parentNode === oldParent) {
|
|
@@ -2125,6 +2329,54 @@ function _morphChildrenKeyed(oldParent, oldChildren, newChildren, oldKeyMap, new
|
|
|
2125
2329
|
}
|
|
2126
2330
|
}
|
|
2127
2331
|
|
|
2332
|
+
/**
|
|
2333
|
+
* Compute the Longest Increasing Subsequence of an index array.
|
|
2334
|
+
* Returns a Set of positions (in the input) that form the LIS.
|
|
2335
|
+
* Entries with value -1 (unmatched) are excluded.
|
|
2336
|
+
*
|
|
2337
|
+
* O(n log n) — same algorithm used by Vue 3 and ivi.
|
|
2338
|
+
*
|
|
2339
|
+
* @param {number[]} arr — array of old-tree indices (-1 = unmatched)
|
|
2340
|
+
* @returns {Set<number>} — positions in arr belonging to the LIS
|
|
2341
|
+
*/
|
|
2342
|
+
function _lis(arr) {
|
|
2343
|
+
const len = arr.length;
|
|
2344
|
+
const result = new Set();
|
|
2345
|
+
if (len === 0) return result;
|
|
2346
|
+
|
|
2347
|
+
// tails[i] = index in arr of the smallest tail element for LIS of length i+1
|
|
2348
|
+
const tails = [];
|
|
2349
|
+
// prev[i] = predecessor index in arr for the LIS ending at arr[i]
|
|
2350
|
+
const prev = new Array(len).fill(-1);
|
|
2351
|
+
const tailIndices = []; // parallel to tails: actual positions
|
|
2352
|
+
|
|
2353
|
+
for (let i = 0; i < len; i++) {
|
|
2354
|
+
if (arr[i] === -1) continue;
|
|
2355
|
+
const val = arr[i];
|
|
2356
|
+
|
|
2357
|
+
// Binary search for insertion point in tails
|
|
2358
|
+
let lo = 0, hi = tails.length;
|
|
2359
|
+
while (lo < hi) {
|
|
2360
|
+
const mid = (lo + hi) >> 1;
|
|
2361
|
+
if (tails[mid] < val) lo = mid + 1;
|
|
2362
|
+
else hi = mid;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
tails[lo] = val;
|
|
2366
|
+
tailIndices[lo] = i;
|
|
2367
|
+
prev[i] = lo > 0 ? tailIndices[lo - 1] : -1;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// Reconstruct: walk backwards from the last element of LIS
|
|
2371
|
+
let k = tailIndices[tails.length - 1];
|
|
2372
|
+
for (let i = tails.length - 1; i >= 0; i--) {
|
|
2373
|
+
result.add(k);
|
|
2374
|
+
k = prev[k];
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
return result;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2128
2380
|
/**
|
|
2129
2381
|
* Morph a single node in place.
|
|
2130
2382
|
*/
|
|
@@ -2151,10 +2403,18 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
2151
2403
|
|
|
2152
2404
|
// Both are elements — diff attributes then recurse children
|
|
2153
2405
|
if (oldNode.nodeType === 1) {
|
|
2406
|
+
// z-skip: developer opt-out — skip diffing this subtree entirely.
|
|
2407
|
+
// Useful for third-party widgets, canvas, video, or large static content.
|
|
2408
|
+
if (oldNode.hasAttribute('z-skip')) return;
|
|
2409
|
+
|
|
2410
|
+
// Fast bail-out: if the elements are identical, skip everything.
|
|
2411
|
+
// isEqualNode() is a native C++ comparison — much faster than walking
|
|
2412
|
+
// attributes + children in JS when trees haven't changed.
|
|
2413
|
+
if (oldNode.isEqualNode(newNode)) return;
|
|
2414
|
+
|
|
2154
2415
|
_morphAttributes(oldNode, newNode);
|
|
2155
2416
|
|
|
2156
2417
|
// Special elements: don't recurse into their children
|
|
2157
|
-
// (textarea value, input value, select, etc.)
|
|
2158
2418
|
const tag = oldNode.nodeName;
|
|
2159
2419
|
if (tag === 'INPUT') {
|
|
2160
2420
|
_syncInputValue(oldNode, newNode);
|
|
@@ -2167,7 +2427,6 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
2167
2427
|
return;
|
|
2168
2428
|
}
|
|
2169
2429
|
if (tag === 'SELECT') {
|
|
2170
|
-
// Recurse children (options) then sync value
|
|
2171
2430
|
_morphChildren(oldNode, newNode);
|
|
2172
2431
|
if (oldNode.value !== newNode.value) {
|
|
2173
2432
|
oldNode.value = newNode.value;
|
|
@@ -2182,23 +2441,45 @@ function _morphNode(parent, oldNode, newNode) {
|
|
|
2182
2441
|
|
|
2183
2442
|
/**
|
|
2184
2443
|
* Sync attributes from newEl onto oldEl.
|
|
2444
|
+
* Uses a single pass: build a set of new attribute names, iterate
|
|
2445
|
+
* old attrs for removals, then apply new attrs.
|
|
2185
2446
|
*/
|
|
2186
2447
|
function _morphAttributes(oldEl, newEl) {
|
|
2187
|
-
// Add/update attributes
|
|
2188
2448
|
const newAttrs = newEl.attributes;
|
|
2189
|
-
|
|
2449
|
+
const oldAttrs = oldEl.attributes;
|
|
2450
|
+
const newLen = newAttrs.length;
|
|
2451
|
+
const oldLen = oldAttrs.length;
|
|
2452
|
+
|
|
2453
|
+
// Fast path: if both have same number of attributes, check if they're identical
|
|
2454
|
+
if (newLen === oldLen) {
|
|
2455
|
+
let same = true;
|
|
2456
|
+
for (let i = 0; i < newLen; i++) {
|
|
2457
|
+
const na = newAttrs[i];
|
|
2458
|
+
if (oldEl.getAttribute(na.name) !== na.value) { same = false; break; }
|
|
2459
|
+
}
|
|
2460
|
+
if (same) {
|
|
2461
|
+
// Also verify no extra old attrs (names mismatch)
|
|
2462
|
+
for (let i = 0; i < oldLen; i++) {
|
|
2463
|
+
if (!newEl.hasAttribute(oldAttrs[i].name)) { same = false; break; }
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
if (same) return;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// Build set of new attr names for O(1) lookup during removal pass
|
|
2470
|
+
const newNames = new Set();
|
|
2471
|
+
for (let i = 0; i < newLen; i++) {
|
|
2190
2472
|
const attr = newAttrs[i];
|
|
2473
|
+
newNames.add(attr.name);
|
|
2191
2474
|
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
|
2192
2475
|
oldEl.setAttribute(attr.name, attr.value);
|
|
2193
2476
|
}
|
|
2194
2477
|
}
|
|
2195
2478
|
|
|
2196
2479
|
// Remove stale attributes
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
if (!newEl.hasAttribute(attr.name)) {
|
|
2201
|
-
oldEl.removeAttribute(attr.name);
|
|
2480
|
+
for (let i = oldLen - 1; i >= 0; i--) {
|
|
2481
|
+
if (!newNames.has(oldAttrs[i].name)) {
|
|
2482
|
+
oldEl.removeAttribute(oldAttrs[i].name);
|
|
2202
2483
|
}
|
|
2203
2484
|
}
|
|
2204
2485
|
}
|
|
@@ -2222,12 +2503,36 @@ function _syncInputValue(oldEl, newEl) {
|
|
|
2222
2503
|
}
|
|
2223
2504
|
|
|
2224
2505
|
/**
|
|
2225
|
-
* Get the reconciliation key from a node
|
|
2506
|
+
* Get the reconciliation key from a node.
|
|
2507
|
+
*
|
|
2508
|
+
* Priority: z-key attribute → id attribute → data-id / data-key.
|
|
2509
|
+
* Auto-detected keys use a `\0` prefix to avoid collisions with
|
|
2510
|
+
* explicit z-key values.
|
|
2511
|
+
*
|
|
2512
|
+
* This means the LIS-optimised keyed path activates automatically
|
|
2513
|
+
* whenever elements carry `id` or `data-id` / `data-key` attributes
|
|
2514
|
+
* — no extra markup required.
|
|
2515
|
+
*
|
|
2226
2516
|
* @returns {string|null}
|
|
2227
2517
|
*/
|
|
2228
2518
|
function _getKey(node) {
|
|
2229
2519
|
if (node.nodeType !== 1) return null;
|
|
2230
|
-
|
|
2520
|
+
|
|
2521
|
+
// Explicit z-key — highest priority
|
|
2522
|
+
const zk = node.getAttribute('z-key');
|
|
2523
|
+
if (zk) return zk;
|
|
2524
|
+
|
|
2525
|
+
// Auto-key: id attribute (unique by spec)
|
|
2526
|
+
if (node.id) return '\0id:' + node.id;
|
|
2527
|
+
|
|
2528
|
+
// Auto-key: data-id or data-key attributes
|
|
2529
|
+
const ds = node.dataset;
|
|
2530
|
+
if (ds) {
|
|
2531
|
+
if (ds.id) return '\0data-id:' + ds.id;
|
|
2532
|
+
if (ds.key) return '\0data-key:' + ds.key;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return null;
|
|
2231
2536
|
}
|
|
2232
2537
|
|
|
2233
2538
|
// --- src/component.js --------------------------------------------
|
|
@@ -2571,7 +2876,7 @@ class Component {
|
|
|
2571
2876
|
// Normalize items → [{id, label}, …]
|
|
2572
2877
|
def._pages = (p.items || []).map(item => {
|
|
2573
2878
|
if (typeof item === 'string') return { id: item, label: _titleCase(item) };
|
|
2574
|
-
return {
|
|
2879
|
+
return { ...item, label: item.label || _titleCase(item.id) };
|
|
2575
2880
|
});
|
|
2576
2881
|
|
|
2577
2882
|
// Build URL map for lazy per-page loading.
|
|
@@ -2704,6 +3009,11 @@ class Component {
|
|
|
2704
3009
|
html = '';
|
|
2705
3010
|
}
|
|
2706
3011
|
|
|
3012
|
+
// Pre-expand z-html and z-text at string level so the morph engine
|
|
3013
|
+
// can diff their content properly (instead of clearing + re-injecting
|
|
3014
|
+
// on every re-render). Same pattern as z-for: parse → evaluate → serialize.
|
|
3015
|
+
html = this._expandContentDirectives(html);
|
|
3016
|
+
|
|
2707
3017
|
// -- Slot distribution ----------------------------------------
|
|
2708
3018
|
// Replace <slot> elements with captured slot content from parent.
|
|
2709
3019
|
// <slot> → default slot content
|
|
@@ -2793,8 +3103,10 @@ class Component {
|
|
|
2793
3103
|
|
|
2794
3104
|
// Update DOM via morphing (diffing) — preserves unchanged nodes
|
|
2795
3105
|
// First render uses innerHTML for speed; subsequent renders morph.
|
|
3106
|
+
const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
|
|
2796
3107
|
if (!this._mounted) {
|
|
2797
3108
|
this._el.innerHTML = html;
|
|
3109
|
+
if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
|
|
2798
3110
|
} else {
|
|
2799
3111
|
morph(this._el, html);
|
|
2800
3112
|
}
|
|
@@ -2837,31 +3149,31 @@ class Component {
|
|
|
2837
3149
|
}
|
|
2838
3150
|
}
|
|
2839
3151
|
|
|
2840
|
-
// Bind @event="method" and z-on:event="method" handlers via delegation
|
|
3152
|
+
// Bind @event="method" and z-on:event="method" handlers via delegation.
|
|
3153
|
+
//
|
|
3154
|
+
// Optimization: on the FIRST render, we scan for event attributes, build
|
|
3155
|
+
// a delegated handler map, and attach one listener per event type to the
|
|
3156
|
+
// component root. On subsequent renders (re-bind), we only rebuild the
|
|
3157
|
+
// internal binding map — existing DOM listeners are reused since they
|
|
3158
|
+
// delegate to event.target.closest(selector) at fire time.
|
|
2841
3159
|
_bindEvents() {
|
|
2842
|
-
//
|
|
2843
|
-
|
|
2844
|
-
this._el.removeEventListener(event, handler);
|
|
2845
|
-
});
|
|
2846
|
-
this._listeners = [];
|
|
3160
|
+
// Always rebuild the binding map from current DOM
|
|
3161
|
+
const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
|
|
2847
3162
|
|
|
2848
|
-
// Find all elements with @event or z-on:event attributes
|
|
2849
3163
|
const allEls = this._el.querySelectorAll('*');
|
|
2850
|
-
const eventMap = new Map(); // event → [{ selector, method, modifiers }]
|
|
2851
|
-
|
|
2852
3164
|
allEls.forEach(child => {
|
|
2853
|
-
// Skip elements inside z-pre subtrees
|
|
2854
3165
|
if (child.closest('[z-pre]')) return;
|
|
2855
3166
|
|
|
2856
|
-
|
|
2857
|
-
|
|
3167
|
+
const attrs = child.attributes;
|
|
3168
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
3169
|
+
const attr = attrs[a];
|
|
2858
3170
|
let raw;
|
|
2859
|
-
if (attr.name.
|
|
2860
|
-
raw = attr.name.slice(1);
|
|
3171
|
+
if (attr.name.charCodeAt(0) === 64) { // '@'
|
|
3172
|
+
raw = attr.name.slice(1);
|
|
2861
3173
|
} else if (attr.name.startsWith('z-on:')) {
|
|
2862
|
-
raw = attr.name.slice(5);
|
|
3174
|
+
raw = attr.name.slice(5);
|
|
2863
3175
|
} else {
|
|
2864
|
-
|
|
3176
|
+
continue;
|
|
2865
3177
|
}
|
|
2866
3178
|
|
|
2867
3179
|
const parts = raw.split('.');
|
|
@@ -2877,12 +3189,45 @@ class Component {
|
|
|
2877
3189
|
|
|
2878
3190
|
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
2879
3191
|
eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
|
|
2880
|
-
}
|
|
3192
|
+
}
|
|
2881
3193
|
});
|
|
2882
3194
|
|
|
3195
|
+
// Store binding map for the delegated handlers to reference
|
|
3196
|
+
this._eventBindings = eventMap;
|
|
3197
|
+
|
|
3198
|
+
// Only attach DOM listeners once — reuse on subsequent renders.
|
|
3199
|
+
// The handlers close over `this` and read `this._eventBindings`
|
|
3200
|
+
// at fire time, so they always use the latest binding map.
|
|
3201
|
+
if (this._delegatedEvents) {
|
|
3202
|
+
// Already attached — just make sure new event types are covered
|
|
3203
|
+
for (const event of eventMap.keys()) {
|
|
3204
|
+
if (!this._delegatedEvents.has(event)) {
|
|
3205
|
+
this._attachDelegatedEvent(event, eventMap.get(event));
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
// Remove listeners for event types no longer in the template
|
|
3209
|
+
for (const event of this._delegatedEvents.keys()) {
|
|
3210
|
+
if (!eventMap.has(event)) {
|
|
3211
|
+
const { handler, opts } = this._delegatedEvents.get(event);
|
|
3212
|
+
this._el.removeEventListener(event, handler, opts);
|
|
3213
|
+
this._delegatedEvents.delete(event);
|
|
3214
|
+
// Also remove from _listeners array
|
|
3215
|
+
this._listeners = this._listeners.filter(l => l.event !== event);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
this._delegatedEvents = new Map();
|
|
3222
|
+
|
|
2883
3223
|
// Register delegated listeners on the component root
|
|
2884
3224
|
for (const [event, bindings] of eventMap) {
|
|
2885
|
-
|
|
3225
|
+
this._attachDelegatedEvent(event, bindings);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// Attach a single delegated listener for an event type
|
|
3230
|
+
_attachDelegatedEvent(event, bindings) {
|
|
2886
3231
|
const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
|
|
2887
3232
|
const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
|
|
2888
3233
|
const listenerOpts = (needsCapture || needsPassive)
|
|
@@ -2890,7 +3235,9 @@ class Component {
|
|
|
2890
3235
|
: false;
|
|
2891
3236
|
|
|
2892
3237
|
const handler = (e) => {
|
|
2893
|
-
|
|
3238
|
+
// Read bindings from live map — always up to date after re-renders
|
|
3239
|
+
const currentBindings = this._eventBindings?.get(event) || [];
|
|
3240
|
+
for (const { selector, methodExpr, modifiers, el } of currentBindings) {
|
|
2894
3241
|
if (!e.target.closest(selector)) continue;
|
|
2895
3242
|
|
|
2896
3243
|
// .self — only fire if target is the element itself
|
|
@@ -2960,7 +3307,7 @@ class Component {
|
|
|
2960
3307
|
};
|
|
2961
3308
|
this._el.addEventListener(event, handler, listenerOpts);
|
|
2962
3309
|
this._listeners.push({ event, handler });
|
|
2963
|
-
|
|
3310
|
+
this._delegatedEvents.set(event, { handler, opts: listenerOpts });
|
|
2964
3311
|
}
|
|
2965
3312
|
|
|
2966
3313
|
// Bind z-ref="name" → this.refs.name
|
|
@@ -2999,7 +3346,7 @@ class Component {
|
|
|
2999
3346
|
// Read current state value (supports dot-path keys)
|
|
3000
3347
|
const currentVal = _getPath(this.state, key);
|
|
3001
3348
|
|
|
3002
|
-
// -- Set initial DOM value from state
|
|
3349
|
+
// -- Set initial DOM value from state (always sync) ----------
|
|
3003
3350
|
if (tag === 'input' && type === 'checkbox') {
|
|
3004
3351
|
el.checked = !!currentVal;
|
|
3005
3352
|
} else if (tag === 'input' && type === 'radio') {
|
|
@@ -3021,6 +3368,11 @@ class Component {
|
|
|
3021
3368
|
: isEditable ? 'input' : 'input';
|
|
3022
3369
|
|
|
3023
3370
|
// -- Handler: read DOM → write to reactive state -------------
|
|
3371
|
+
// Skip if already bound (morph preserves existing elements,
|
|
3372
|
+
// so re-binding would stack duplicate listeners)
|
|
3373
|
+
if (el._zqModelBound) return;
|
|
3374
|
+
el._zqModelBound = true;
|
|
3375
|
+
|
|
3024
3376
|
const handler = () => {
|
|
3025
3377
|
let val;
|
|
3026
3378
|
if (type === 'checkbox') val = el.checked;
|
|
@@ -3140,6 +3492,41 @@ class Component {
|
|
|
3140
3492
|
return temp.innerHTML;
|
|
3141
3493
|
}
|
|
3142
3494
|
|
|
3495
|
+
// ---------------------------------------------------------------------------
|
|
3496
|
+
// _expandContentDirectives — Pre-morph z-html & z-text expansion
|
|
3497
|
+
//
|
|
3498
|
+
// Evaluates z-html and z-text directives at the string level so the morph
|
|
3499
|
+
// engine receives HTML with the actual content inline. This lets the diff
|
|
3500
|
+
// algorithm properly compare old vs new content (text nodes, child elements)
|
|
3501
|
+
// instead of clearing + re-injecting on every re-render.
|
|
3502
|
+
//
|
|
3503
|
+
// Same parse → evaluate → serialize pattern as _expandZFor.
|
|
3504
|
+
// ---------------------------------------------------------------------------
|
|
3505
|
+
_expandContentDirectives(html) {
|
|
3506
|
+
if (!html.includes('z-html') && !html.includes('z-text')) return html;
|
|
3507
|
+
|
|
3508
|
+
const temp = document.createElement('div');
|
|
3509
|
+
temp.innerHTML = html;
|
|
3510
|
+
|
|
3511
|
+
// z-html: evaluate expression → inject as innerHTML
|
|
3512
|
+
temp.querySelectorAll('[z-html]').forEach(el => {
|
|
3513
|
+
if (el.closest('[z-pre]')) return;
|
|
3514
|
+
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
3515
|
+
el.innerHTML = val != null ? String(val) : '';
|
|
3516
|
+
el.removeAttribute('z-html');
|
|
3517
|
+
});
|
|
3518
|
+
|
|
3519
|
+
// z-text: evaluate expression → inject as textContent (HTML-safe)
|
|
3520
|
+
temp.querySelectorAll('[z-text]').forEach(el => {
|
|
3521
|
+
if (el.closest('[z-pre]')) return;
|
|
3522
|
+
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
3523
|
+
el.textContent = val != null ? String(val) : '';
|
|
3524
|
+
el.removeAttribute('z-text');
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
return temp.innerHTML;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3143
3530
|
// ---------------------------------------------------------------------------
|
|
3144
3531
|
// _processDirectives — Post-innerHTML DOM-level directive processing
|
|
3145
3532
|
// ---------------------------------------------------------------------------
|
|
@@ -3192,25 +3579,36 @@ class Component {
|
|
|
3192
3579
|
});
|
|
3193
3580
|
|
|
3194
3581
|
// -- z-bind:attr / :attr (dynamic attribute binding) -----------
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3582
|
+
// Use TreeWalker instead of querySelectorAll('*') — avoids
|
|
3583
|
+
// creating a flat array of every single descendant element.
|
|
3584
|
+
// TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
|
|
3585
|
+
// at the walker level (faster than per-node closest('[z-pre]') checks).
|
|
3586
|
+
const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
|
|
3587
|
+
acceptNode(n) {
|
|
3588
|
+
return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
3589
|
+
}
|
|
3590
|
+
});
|
|
3591
|
+
let node;
|
|
3592
|
+
while ((node = walker.nextNode())) {
|
|
3593
|
+
const attrs = node.attributes;
|
|
3594
|
+
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
3595
|
+
const attr = attrs[i];
|
|
3198
3596
|
let attrName;
|
|
3199
3597
|
if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
|
|
3200
|
-
else if (attr.name.
|
|
3201
|
-
else
|
|
3598
|
+
else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
|
|
3599
|
+
else continue;
|
|
3202
3600
|
|
|
3203
3601
|
const val = this._evalExpr(attr.value);
|
|
3204
|
-
|
|
3602
|
+
node.removeAttribute(attr.name);
|
|
3205
3603
|
if (val === false || val === null || val === undefined) {
|
|
3206
|
-
|
|
3604
|
+
node.removeAttribute(attrName);
|
|
3207
3605
|
} else if (val === true) {
|
|
3208
|
-
|
|
3606
|
+
node.setAttribute(attrName, '');
|
|
3209
3607
|
} else {
|
|
3210
|
-
|
|
3608
|
+
node.setAttribute(attrName, String(val));
|
|
3211
3609
|
}
|
|
3212
|
-
}
|
|
3213
|
-
}
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3214
3612
|
|
|
3215
3613
|
// -- z-class (dynamic class binding) ---------------------------
|
|
3216
3614
|
this._el.querySelectorAll('[z-class]').forEach(el => {
|
|
@@ -3242,21 +3640,9 @@ class Component {
|
|
|
3242
3640
|
el.removeAttribute('z-style');
|
|
3243
3641
|
});
|
|
3244
3642
|
|
|
3245
|
-
//
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
3249
|
-
el.innerHTML = val != null ? String(val) : '';
|
|
3250
|
-
el.removeAttribute('z-html');
|
|
3251
|
-
});
|
|
3252
|
-
|
|
3253
|
-
// -- z-text (safe textContent binding) -------------------------
|
|
3254
|
-
this._el.querySelectorAll('[z-text]').forEach(el => {
|
|
3255
|
-
if (el.closest('[z-pre]')) return;
|
|
3256
|
-
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
3257
|
-
el.textContent = val != null ? String(val) : '';
|
|
3258
|
-
el.removeAttribute('z-text');
|
|
3259
|
-
});
|
|
3643
|
+
// z-html and z-text are now pre-expanded at string level (before
|
|
3644
|
+
// morph) via _expandContentDirectives(), so the diff engine can
|
|
3645
|
+
// properly diff their content instead of clearing + re-injecting.
|
|
3260
3646
|
|
|
3261
3647
|
// -- z-cloak (remove after render) -----------------------------
|
|
3262
3648
|
this._el.querySelectorAll('[z-cloak]').forEach(el => {
|
|
@@ -3289,6 +3675,8 @@ class Component {
|
|
|
3289
3675
|
}
|
|
3290
3676
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
3291
3677
|
this._listeners = [];
|
|
3678
|
+
this._delegatedEvents = null;
|
|
3679
|
+
this._eventBindings = null;
|
|
3292
3680
|
if (this._styleEl) this._styleEl.remove();
|
|
3293
3681
|
_instances.delete(this._el);
|
|
3294
3682
|
this._el.innerHTML = '';
|
|
@@ -3439,6 +3827,36 @@ function getRegistry() {
|
|
|
3439
3827
|
return Object.fromEntries(_registry);
|
|
3440
3828
|
}
|
|
3441
3829
|
|
|
3830
|
+
/**
|
|
3831
|
+
* Pre-load a component's external templates and styles so the next mount
|
|
3832
|
+
* renders synchronously (no blank flash while fetching).
|
|
3833
|
+
* Safe to call multiple times — skips if already loaded.
|
|
3834
|
+
* @param {string} name — registered component name
|
|
3835
|
+
* @returns {Promise<void>}
|
|
3836
|
+
*/
|
|
3837
|
+
async function prefetch(name) {
|
|
3838
|
+
const def = _registry.get(name);
|
|
3839
|
+
if (!def) return;
|
|
3840
|
+
|
|
3841
|
+
// Load templateUrl, styleUrl, and normalize pages config
|
|
3842
|
+
if ((def.templateUrl && !def._templateLoaded) ||
|
|
3843
|
+
(def.styleUrl && !def._styleLoaded) ||
|
|
3844
|
+
(def.pages && !def._pagesNormalized)) {
|
|
3845
|
+
await Component.prototype._loadExternals.call({ _def: def });
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
// For pages-based components, prefetch ALL page templates so any
|
|
3849
|
+
// active page renders instantly on mount.
|
|
3850
|
+
if (def._pageUrls && def._externalTemplates) {
|
|
3851
|
+
const missing = Object.entries(def._pageUrls)
|
|
3852
|
+
.filter(([id]) => !(id in def._externalTemplates));
|
|
3853
|
+
if (missing.length) {
|
|
3854
|
+
const results = await Promise.all(missing.map(([, url]) => _fetchResource(url)));
|
|
3855
|
+
missing.forEach(([id], i) => { def._externalTemplates[id] = results[i]; });
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3442
3860
|
|
|
3443
3861
|
// ---------------------------------------------------------------------------
|
|
3444
3862
|
// Global stylesheet loader
|
|
@@ -3546,6 +3964,7 @@ function style(urls, opts = {}) {
|
|
|
3546
3964
|
*
|
|
3547
3965
|
* Supports hash mode (#/path) and history mode (/path).
|
|
3548
3966
|
* Route params, query strings, navigation guards, and lazy loading.
|
|
3967
|
+
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
3549
3968
|
*
|
|
3550
3969
|
* Usage:
|
|
3551
3970
|
* $.router({
|
|
@@ -3562,6 +3981,9 @@ function style(urls, opts = {}) {
|
|
|
3562
3981
|
|
|
3563
3982
|
|
|
3564
3983
|
|
|
3984
|
+
// Unique marker on history.state to identify zQuery-managed entries
|
|
3985
|
+
const _ZQ_STATE_KEY = '__zq';
|
|
3986
|
+
|
|
3565
3987
|
class Router {
|
|
3566
3988
|
constructor(config = {}) {
|
|
3567
3989
|
this._el = null;
|
|
@@ -3595,6 +4017,10 @@ class Router {
|
|
|
3595
4017
|
this._instance = null; // current mounted component
|
|
3596
4018
|
this._resolving = false; // re-entrancy guard
|
|
3597
4019
|
|
|
4020
|
+
// Sub-route history substates
|
|
4021
|
+
this._substateListeners = []; // [(key, data) => bool|void]
|
|
4022
|
+
this._inSubstate = false; // true while substate entries are in the history stack
|
|
4023
|
+
|
|
3598
4024
|
// Set outlet element
|
|
3599
4025
|
if (config.el) {
|
|
3600
4026
|
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
@@ -3609,7 +4035,21 @@ class Router {
|
|
|
3609
4035
|
if (this._mode === 'hash') {
|
|
3610
4036
|
window.addEventListener('hashchange', () => this._resolve());
|
|
3611
4037
|
} else {
|
|
3612
|
-
window.addEventListener('popstate', () =>
|
|
4038
|
+
window.addEventListener('popstate', (e) => {
|
|
4039
|
+
// Check for substate pop first — if a listener handles it, don't route
|
|
4040
|
+
const st = e.state;
|
|
4041
|
+
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
4042
|
+
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
4043
|
+
if (handled) return;
|
|
4044
|
+
// Unhandled substate — fall through to route resolve
|
|
4045
|
+
// _inSubstate stays true so the next non-substate pop triggers reset
|
|
4046
|
+
} else if (this._inSubstate) {
|
|
4047
|
+
// Popped past all substates — notify listeners to reset to defaults
|
|
4048
|
+
this._inSubstate = false;
|
|
4049
|
+
this._fireSubstate(null, null, 'reset');
|
|
4050
|
+
}
|
|
4051
|
+
this._resolve();
|
|
4052
|
+
});
|
|
3613
4053
|
}
|
|
3614
4054
|
|
|
3615
4055
|
// Intercept link clicks for SPA navigation
|
|
@@ -3692,6 +4132,19 @@ class Router {
|
|
|
3692
4132
|
});
|
|
3693
4133
|
}
|
|
3694
4134
|
|
|
4135
|
+
/**
|
|
4136
|
+
* Get the full current URL (path + hash) for same-URL detection.
|
|
4137
|
+
* @returns {string}
|
|
4138
|
+
*/
|
|
4139
|
+
_currentURL() {
|
|
4140
|
+
if (this._mode === 'hash') {
|
|
4141
|
+
return window.location.hash.slice(1) || '/';
|
|
4142
|
+
}
|
|
4143
|
+
const pathname = window.location.pathname || '/';
|
|
4144
|
+
const hash = window.location.hash || '';
|
|
4145
|
+
return pathname + hash;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
3695
4148
|
navigate(path, options = {}) {
|
|
3696
4149
|
// Interpolate :param placeholders if options.params is provided
|
|
3697
4150
|
if (options.params) path = this._interpolateParams(path, options.params);
|
|
@@ -3703,9 +4156,48 @@ class Router {
|
|
|
3703
4156
|
// Hash mode uses the URL hash for routing, so a #fragment can't live
|
|
3704
4157
|
// in the URL. Store it as a scroll target for the destination component.
|
|
3705
4158
|
if (fragment) window.__zqScrollTarget = fragment;
|
|
3706
|
-
|
|
4159
|
+
const targetHash = '#' + normalized;
|
|
4160
|
+
// Skip if already at this exact hash (prevents duplicate entries)
|
|
4161
|
+
if (window.location.hash === targetHash && !options.force) return this;
|
|
4162
|
+
window.location.hash = targetHash;
|
|
3707
4163
|
} else {
|
|
3708
|
-
|
|
4164
|
+
const targetURL = this._base + normalized + hash;
|
|
4165
|
+
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
4166
|
+
|
|
4167
|
+
if (targetURL === currentURL && !options.force) {
|
|
4168
|
+
// Same full URL (path + hash) — don't push duplicate entry.
|
|
4169
|
+
// If only the hash changed to a fragment target, scroll to it.
|
|
4170
|
+
if (fragment) {
|
|
4171
|
+
const el = document.getElementById(fragment);
|
|
4172
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4173
|
+
}
|
|
4174
|
+
return this;
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
// Same route path but different hash fragment — use replaceState
|
|
4178
|
+
// so back goes to the previous *route*, not the previous scroll position.
|
|
4179
|
+
const targetPathOnly = this._base + normalized;
|
|
4180
|
+
const currentPathOnly = window.location.pathname || '/';
|
|
4181
|
+
if (targetPathOnly === currentPathOnly && hash && !options.force) {
|
|
4182
|
+
window.history.replaceState(
|
|
4183
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4184
|
+
'',
|
|
4185
|
+
targetURL
|
|
4186
|
+
);
|
|
4187
|
+
// Scroll to the fragment target
|
|
4188
|
+
if (fragment) {
|
|
4189
|
+
const el = document.getElementById(fragment);
|
|
4190
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4191
|
+
}
|
|
4192
|
+
// Don't re-resolve — same route, just a hash change
|
|
4193
|
+
return this;
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
window.history.pushState(
|
|
4197
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4198
|
+
'',
|
|
4199
|
+
targetURL
|
|
4200
|
+
);
|
|
3709
4201
|
this._resolve();
|
|
3710
4202
|
}
|
|
3711
4203
|
return this;
|
|
@@ -3721,7 +4213,11 @@ class Router {
|
|
|
3721
4213
|
if (fragment) window.__zqScrollTarget = fragment;
|
|
3722
4214
|
window.location.replace('#' + normalized);
|
|
3723
4215
|
} else {
|
|
3724
|
-
window.history.replaceState(
|
|
4216
|
+
window.history.replaceState(
|
|
4217
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4218
|
+
'',
|
|
4219
|
+
this._base + normalized + hash
|
|
4220
|
+
);
|
|
3725
4221
|
this._resolve();
|
|
3726
4222
|
}
|
|
3727
4223
|
return this;
|
|
@@ -3776,6 +4272,80 @@ class Router {
|
|
|
3776
4272
|
return () => this._listeners.delete(fn);
|
|
3777
4273
|
}
|
|
3778
4274
|
|
|
4275
|
+
// --- Sub-route history substates -----------------------------------------
|
|
4276
|
+
|
|
4277
|
+
/**
|
|
4278
|
+
* Push a lightweight history entry for in-component UI state.
|
|
4279
|
+
* The URL path does NOT change — only a history entry is added so the
|
|
4280
|
+
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
4281
|
+
* before navigating away.
|
|
4282
|
+
*
|
|
4283
|
+
* @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
|
|
4284
|
+
* @param {*} data — arbitrary state (serializable)
|
|
4285
|
+
* @returns {Router}
|
|
4286
|
+
*
|
|
4287
|
+
* @example
|
|
4288
|
+
* // Open a modal and push a substate
|
|
4289
|
+
* router.pushSubstate('modal', { id: 'confirm-delete' });
|
|
4290
|
+
* // User hits back → onSubstate fires → close the modal
|
|
4291
|
+
*/
|
|
4292
|
+
pushSubstate(key, data) {
|
|
4293
|
+
this._inSubstate = true;
|
|
4294
|
+
if (this._mode === 'hash') {
|
|
4295
|
+
// Hash mode: stash the substate in a global — hashchange will check.
|
|
4296
|
+
// We still push a history entry via a sentinel hash suffix.
|
|
4297
|
+
const current = window.location.hash || '#/';
|
|
4298
|
+
window.history.pushState(
|
|
4299
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
4300
|
+
'',
|
|
4301
|
+
window.location.href
|
|
4302
|
+
);
|
|
4303
|
+
} else {
|
|
4304
|
+
window.history.pushState(
|
|
4305
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
4306
|
+
'',
|
|
4307
|
+
window.location.href // keep same URL
|
|
4308
|
+
);
|
|
4309
|
+
}
|
|
4310
|
+
return this;
|
|
4311
|
+
}
|
|
4312
|
+
|
|
4313
|
+
/**
|
|
4314
|
+
* Register a listener for substate pops (back button on a substate entry).
|
|
4315
|
+
* The callback receives `(key, data)` and should return `true` if it
|
|
4316
|
+
* handled the pop (prevents route resolution). If no listener returns
|
|
4317
|
+
* `true`, normal route resolution proceeds.
|
|
4318
|
+
*
|
|
4319
|
+
* @param {(key: string, data: any, action: string) => boolean|void} fn
|
|
4320
|
+
* @returns {() => void} unsubscribe function
|
|
4321
|
+
*
|
|
4322
|
+
* @example
|
|
4323
|
+
* const unsub = router.onSubstate((key, data) => {
|
|
4324
|
+
* if (key === 'modal') { closeModal(); return true; }
|
|
4325
|
+
* });
|
|
4326
|
+
*/
|
|
4327
|
+
onSubstate(fn) {
|
|
4328
|
+
this._substateListeners.push(fn);
|
|
4329
|
+
return () => {
|
|
4330
|
+
this._substateListeners = this._substateListeners.filter(f => f !== fn);
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
/**
|
|
4335
|
+
* Fire substate listeners. Returns true if any listener handled it.
|
|
4336
|
+
* @private
|
|
4337
|
+
*/
|
|
4338
|
+
_fireSubstate(key, data, action) {
|
|
4339
|
+
for (const fn of this._substateListeners) {
|
|
4340
|
+
try {
|
|
4341
|
+
if (fn(key, data, action) === true) return true;
|
|
4342
|
+
} catch (err) {
|
|
4343
|
+
reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
return false;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
3779
4349
|
// --- Current state -------------------------------------------------------
|
|
3780
4350
|
|
|
3781
4351
|
get current() { return this._current; }
|
|
@@ -3836,6 +4406,16 @@ class Router {
|
|
|
3836
4406
|
}
|
|
3837
4407
|
|
|
3838
4408
|
async __resolve() {
|
|
4409
|
+
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
4410
|
+
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
4411
|
+
// if handled — the URL hasn't changed so there's no route to resolve.
|
|
4412
|
+
const histState = window.history.state;
|
|
4413
|
+
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
4414
|
+
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
4415
|
+
if (handled) return;
|
|
4416
|
+
// No listener handled it — fall through to normal routing
|
|
4417
|
+
}
|
|
4418
|
+
|
|
3839
4419
|
const fullPath = this.path;
|
|
3840
4420
|
const [pathPart, queryString] = fullPath.split('?');
|
|
3841
4421
|
const path = pathPart || '/';
|
|
@@ -3863,6 +4443,18 @@ class Router {
|
|
|
3863
4443
|
const to = { route: matched, params, query, path };
|
|
3864
4444
|
const from = this._current;
|
|
3865
4445
|
|
|
4446
|
+
// Same-route optimization: if the resolved route is the same component
|
|
4447
|
+
// with the same params, skip the full destroy/mount cycle and just
|
|
4448
|
+
// update props. This prevents flashing and unnecessary DOM churn.
|
|
4449
|
+
if (from && this._instance && matched.component === from.route.component) {
|
|
4450
|
+
const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
|
|
4451
|
+
const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
|
|
4452
|
+
if (sameParams && sameQuery) {
|
|
4453
|
+
// Identical navigation — nothing to do
|
|
4454
|
+
return;
|
|
4455
|
+
}
|
|
4456
|
+
}
|
|
4457
|
+
|
|
3866
4458
|
// Run before guards
|
|
3867
4459
|
for (const guard of this._guards.before) {
|
|
3868
4460
|
try {
|
|
@@ -3881,7 +4473,11 @@ class Router {
|
|
|
3881
4473
|
if (rFrag) window.__zqScrollTarget = rFrag;
|
|
3882
4474
|
window.location.replace('#' + rNorm);
|
|
3883
4475
|
} else {
|
|
3884
|
-
window.history.replaceState(
|
|
4476
|
+
window.history.replaceState(
|
|
4477
|
+
{ [_ZQ_STATE_KEY]: 'route' },
|
|
4478
|
+
'',
|
|
4479
|
+
this._base + rNorm + rHash
|
|
4480
|
+
);
|
|
3885
4481
|
}
|
|
3886
4482
|
return this.__resolve();
|
|
3887
4483
|
}
|
|
@@ -3904,6 +4500,12 @@ class Router {
|
|
|
3904
4500
|
|
|
3905
4501
|
// Mount component into outlet
|
|
3906
4502
|
if (this._el && matched.component) {
|
|
4503
|
+
// Pre-load external templates/styles so the mount renders synchronously
|
|
4504
|
+
// (keeps old content visible during the fetch instead of showing blank)
|
|
4505
|
+
if (typeof matched.component === 'string') {
|
|
4506
|
+
await prefetch(matched.component);
|
|
4507
|
+
}
|
|
4508
|
+
|
|
3907
4509
|
// Destroy previous
|
|
3908
4510
|
if (this._instance) {
|
|
3909
4511
|
this._instance.destroy();
|
|
@@ -3911,6 +4513,7 @@ class Router {
|
|
|
3911
4513
|
}
|
|
3912
4514
|
|
|
3913
4515
|
// Create container
|
|
4516
|
+
const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
|
|
3914
4517
|
this._el.innerHTML = '';
|
|
3915
4518
|
|
|
3916
4519
|
// Pass route params and query as props
|
|
@@ -3921,10 +4524,12 @@ class Router {
|
|
|
3921
4524
|
const container = document.createElement(matched.component);
|
|
3922
4525
|
this._el.appendChild(container);
|
|
3923
4526
|
this._instance = mount(container, matched.component, props);
|
|
4527
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
|
|
3924
4528
|
}
|
|
3925
4529
|
// If component is a render function
|
|
3926
4530
|
else if (typeof matched.component === 'function') {
|
|
3927
4531
|
this._el.innerHTML = matched.component(to);
|
|
4532
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
|
|
3928
4533
|
}
|
|
3929
4534
|
}
|
|
3930
4535
|
|
|
@@ -3942,6 +4547,8 @@ class Router {
|
|
|
3942
4547
|
destroy() {
|
|
3943
4548
|
if (this._instance) this._instance.destroy();
|
|
3944
4549
|
this._listeners.clear();
|
|
4550
|
+
this._substateListeners = [];
|
|
4551
|
+
this._inSubstate = false;
|
|
3945
4552
|
this._routes = [];
|
|
3946
4553
|
this._guards = { before: [], after: [] };
|
|
3947
4554
|
}
|
|
@@ -4704,8 +5311,10 @@ $.mountAll = mountAll;
|
|
|
4704
5311
|
$.getInstance = getInstance;
|
|
4705
5312
|
$.destroy = destroy;
|
|
4706
5313
|
$.components = getRegistry;
|
|
5314
|
+
$.prefetch = prefetch;
|
|
4707
5315
|
$.style = style;
|
|
4708
|
-
$.morph
|
|
5316
|
+
$.morph = morph;
|
|
5317
|
+
$.morphElement = morphElement;
|
|
4709
5318
|
$.safeEval = safeEval;
|
|
4710
5319
|
|
|
4711
5320
|
// --- Router ----------------------------------------------------------------
|
|
@@ -4746,12 +5355,15 @@ $.session = session;
|
|
|
4746
5355
|
$.bus = bus;
|
|
4747
5356
|
|
|
4748
5357
|
// --- Error handling --------------------------------------------------------
|
|
4749
|
-
$.onError
|
|
4750
|
-
$.ZQueryError
|
|
4751
|
-
$.ErrorCode
|
|
5358
|
+
$.onError = onError;
|
|
5359
|
+
$.ZQueryError = ZQueryError;
|
|
5360
|
+
$.ErrorCode = ErrorCode;
|
|
5361
|
+
$.guardCallback = guardCallback;
|
|
5362
|
+
$.validate = validate;
|
|
4752
5363
|
|
|
4753
5364
|
// --- Meta ------------------------------------------------------------------
|
|
4754
|
-
$.version = '0.
|
|
5365
|
+
$.version = '0.8.6';
|
|
5366
|
+
$.libSize = '~91 KB';
|
|
4755
5367
|
$.meta = {}; // populated at build time by CLI bundler
|
|
4756
5368
|
|
|
4757
5369
|
$.noConflict = () => {
|