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.
- package/README.md +39 -30
- package/cli/commands/build.js +110 -1
- package/cli/commands/bundle.js +127 -50
- 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 +740 -226
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +11 -11
- package/index.js +15 -10
- package/package.json +3 -2
- package/src/component.js +154 -139
- 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 +196 -7
- package/src/ssr.js +1 -1
- 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 +10 -34
- 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.7
|
|
2
|
+
* zQuery (zeroQuery) v0.8.7
|
|
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 --------------------------------------------
|
|
@@ -2248,7 +2553,7 @@ function _getKey(node) {
|
|
|
2248
2553
|
* - Scoped styles (inline or via styleUrl)
|
|
2249
2554
|
* - External templates via templateUrl (with {{expression}} interpolation)
|
|
2250
2555
|
* - External styles via styleUrl (fetched & scoped automatically)
|
|
2251
|
-
* - Relative path resolution — templateUrl
|
|
2556
|
+
* - Relative path resolution — templateUrl and styleUrl
|
|
2252
2557
|
* resolve relative to the component file automatically
|
|
2253
2558
|
*/
|
|
2254
2559
|
|
|
@@ -2318,16 +2623,6 @@ function _fetchResource(url) {
|
|
|
2318
2623
|
return promise;
|
|
2319
2624
|
}
|
|
2320
2625
|
|
|
2321
|
-
/**
|
|
2322
|
-
* Convert a kebab-case id to Title Case.
|
|
2323
|
-
* 'getting-started' → 'Getting Started'
|
|
2324
|
-
* @param {string} id
|
|
2325
|
-
* @returns {string}
|
|
2326
|
-
*/
|
|
2327
|
-
function _titleCase(id) {
|
|
2328
|
-
return id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2329
|
-
}
|
|
2330
|
-
|
|
2331
2626
|
/**
|
|
2332
2627
|
* Resolve a relative URL against a base.
|
|
2333
2628
|
*
|
|
@@ -2546,47 +2841,10 @@ class Component {
|
|
|
2546
2841
|
// - string → single stylesheet
|
|
2547
2842
|
// - string[] → array of URLs → all fetched & concatenated
|
|
2548
2843
|
//
|
|
2549
|
-
// pages config (shorthand for multi-template + route-param page switching):
|
|
2550
|
-
// pages: {
|
|
2551
|
-
// dir: 'pages', // relative to component file (or base)
|
|
2552
|
-
// param: 'section', // route param name → this.activePage
|
|
2553
|
-
// default: 'getting-started', // fallback when param is absent
|
|
2554
|
-
// ext: '.html', // file extension (default '.html')
|
|
2555
|
-
// items: ['page-a', { id: 'page-b', label: 'Page B' }, ...]
|
|
2556
|
-
// }
|
|
2557
|
-
// Exposes this.pages (array of {id,label}), this.activePage (current id)
|
|
2558
|
-
// Pages are lazy-loaded: only the active page is fetched on first render,
|
|
2559
|
-
// remaining pages are prefetched in the background for instant navigation.
|
|
2560
|
-
//
|
|
2561
2844
|
async _loadExternals() {
|
|
2562
2845
|
const def = this._def;
|
|
2563
2846
|
const base = def._base; // auto-detected or explicit
|
|
2564
2847
|
|
|
2565
|
-
// -- Pages config ---------------------------------------------
|
|
2566
|
-
if (def.pages && !def._pagesNormalized) {
|
|
2567
|
-
const p = def.pages;
|
|
2568
|
-
const ext = p.ext || '.html';
|
|
2569
|
-
const dir = _resolveUrl((p.dir || '').replace(/\/+$/, ''), base);
|
|
2570
|
-
|
|
2571
|
-
// Normalize items → [{id, label}, …]
|
|
2572
|
-
def._pages = (p.items || []).map(item => {
|
|
2573
|
-
if (typeof item === 'string') return { id: item, label: _titleCase(item) };
|
|
2574
|
-
return { id: item.id, label: item.label || _titleCase(item.id) };
|
|
2575
|
-
});
|
|
2576
|
-
|
|
2577
|
-
// Build URL map for lazy per-page loading.
|
|
2578
|
-
// Pages are fetched on demand (active page first, rest prefetched in
|
|
2579
|
-
// the background) so the component renders as soon as the visible
|
|
2580
|
-
// page is ready instead of waiting for every page to download.
|
|
2581
|
-
def._pageUrls = {};
|
|
2582
|
-
for (const { id } of def._pages) {
|
|
2583
|
-
def._pageUrls[id] = `${dir}/${id}${ext}`;
|
|
2584
|
-
}
|
|
2585
|
-
if (!def._externalTemplates) def._externalTemplates = {};
|
|
2586
|
-
|
|
2587
|
-
def._pagesNormalized = true;
|
|
2588
|
-
}
|
|
2589
|
-
|
|
2590
2848
|
// -- External templates --------------------------------------
|
|
2591
2849
|
if (def.templateUrl && !def._templateLoaded) {
|
|
2592
2850
|
const tu = def.templateUrl;
|
|
@@ -2599,9 +2857,8 @@ class Component {
|
|
|
2599
2857
|
results.forEach((html, i) => { def._externalTemplates[i] = html; });
|
|
2600
2858
|
} else if (typeof tu === 'object') {
|
|
2601
2859
|
const entries = Object.entries(tu);
|
|
2602
|
-
// Pages config already resolved; plain objects still need resolving
|
|
2603
2860
|
const results = await Promise.all(
|
|
2604
|
-
entries.map(([, url]) => _fetchResource(
|
|
2861
|
+
entries.map(([, url]) => _fetchResource(_resolveUrl(url, base)))
|
|
2605
2862
|
);
|
|
2606
2863
|
def._externalTemplates = {};
|
|
2607
2864
|
entries.forEach(([key], i) => { def._externalTemplates[key] = results[i]; });
|
|
@@ -2630,8 +2887,7 @@ class Component {
|
|
|
2630
2887
|
_render() {
|
|
2631
2888
|
// If externals haven't loaded yet, trigger async load then re-render
|
|
2632
2889
|
if ((this._def.templateUrl && !this._def._templateLoaded) ||
|
|
2633
|
-
(this._def.styleUrl && !this._def._styleLoaded)
|
|
2634
|
-
(this._def.pages && !this._def._pagesNormalized)) {
|
|
2890
|
+
(this._def.styleUrl && !this._def._styleLoaded)) {
|
|
2635
2891
|
this._loadExternals().then(() => {
|
|
2636
2892
|
if (!this._destroyed) this._render();
|
|
2637
2893
|
});
|
|
@@ -2643,43 +2899,6 @@ class Component {
|
|
|
2643
2899
|
this.templates = this._def._externalTemplates;
|
|
2644
2900
|
}
|
|
2645
2901
|
|
|
2646
|
-
// Expose pages metadata and active page (derived from route param)
|
|
2647
|
-
if (this._def._pages) {
|
|
2648
|
-
this.pages = this._def._pages;
|
|
2649
|
-
const pc = this._def.pages;
|
|
2650
|
-
let active = (pc.param && this.props.$params?.[pc.param]) || pc.default || this._def._pages[0]?.id || '';
|
|
2651
|
-
|
|
2652
|
-
// Fall back to default if the param doesn't match any known page
|
|
2653
|
-
if (this._def._pageUrls && !(active in this._def._pageUrls)) {
|
|
2654
|
-
active = pc.default || this._def._pages[0]?.id || '';
|
|
2655
|
-
}
|
|
2656
|
-
this.activePage = active;
|
|
2657
|
-
|
|
2658
|
-
// Lazy-load: fetch only the active page's template on demand
|
|
2659
|
-
if (this._def._pageUrls && !(active in this._def._externalTemplates)) {
|
|
2660
|
-
const url = this._def._pageUrls[active];
|
|
2661
|
-
if (url) {
|
|
2662
|
-
_fetchResource(url).then(html => {
|
|
2663
|
-
this._def._externalTemplates[active] = html;
|
|
2664
|
-
if (!this._destroyed) this._render();
|
|
2665
|
-
});
|
|
2666
|
-
return; // Wait for active page before rendering
|
|
2667
|
-
}
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
// Prefetch remaining pages in background (once, after active page is ready)
|
|
2671
|
-
if (this._def._pageUrls && !this._def._pagesPrefetched) {
|
|
2672
|
-
this._def._pagesPrefetched = true;
|
|
2673
|
-
for (const [id, url] of Object.entries(this._def._pageUrls)) {
|
|
2674
|
-
if (!(id in this._def._externalTemplates)) {
|
|
2675
|
-
_fetchResource(url).then(html => {
|
|
2676
|
-
this._def._externalTemplates[id] = html;
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
}
|
|
2680
|
-
}
|
|
2681
|
-
}
|
|
2682
|
-
|
|
2683
2902
|
// Determine HTML content
|
|
2684
2903
|
let html;
|
|
2685
2904
|
if (this._def.render) {
|
|
@@ -2704,6 +2923,11 @@ class Component {
|
|
|
2704
2923
|
html = '';
|
|
2705
2924
|
}
|
|
2706
2925
|
|
|
2926
|
+
// Pre-expand z-html and z-text at string level so the morph engine
|
|
2927
|
+
// can diff their content properly (instead of clearing + re-injecting
|
|
2928
|
+
// on every re-render). Same pattern as z-for: parse → evaluate → serialize.
|
|
2929
|
+
html = this._expandContentDirectives(html);
|
|
2930
|
+
|
|
2707
2931
|
// -- Slot distribution ----------------------------------------
|
|
2708
2932
|
// Replace <slot> elements with captured slot content from parent.
|
|
2709
2933
|
// <slot> → default slot content
|
|
@@ -2793,8 +3017,10 @@ class Component {
|
|
|
2793
3017
|
|
|
2794
3018
|
// Update DOM via morphing (diffing) — preserves unchanged nodes
|
|
2795
3019
|
// First render uses innerHTML for speed; subsequent renders morph.
|
|
3020
|
+
const _renderStart = typeof window !== 'undefined' && (window.__zqMorphHook || window.__zqRenderHook) ? performance.now() : 0;
|
|
2796
3021
|
if (!this._mounted) {
|
|
2797
3022
|
this._el.innerHTML = html;
|
|
3023
|
+
if (_renderStart && window.__zqRenderHook) window.__zqRenderHook(this._el, performance.now() - _renderStart, 'mount', this._def._name);
|
|
2798
3024
|
} else {
|
|
2799
3025
|
morph(this._el, html);
|
|
2800
3026
|
}
|
|
@@ -2837,31 +3063,31 @@ class Component {
|
|
|
2837
3063
|
}
|
|
2838
3064
|
}
|
|
2839
3065
|
|
|
2840
|
-
// Bind @event="method" and z-on:event="method" handlers via delegation
|
|
3066
|
+
// Bind @event="method" and z-on:event="method" handlers via delegation.
|
|
3067
|
+
//
|
|
3068
|
+
// Optimization: on the FIRST render, we scan for event attributes, build
|
|
3069
|
+
// a delegated handler map, and attach one listener per event type to the
|
|
3070
|
+
// component root. On subsequent renders (re-bind), we only rebuild the
|
|
3071
|
+
// internal binding map — existing DOM listeners are reused since they
|
|
3072
|
+
// delegate to event.target.closest(selector) at fire time.
|
|
2841
3073
|
_bindEvents() {
|
|
2842
|
-
//
|
|
2843
|
-
|
|
2844
|
-
this._el.removeEventListener(event, handler);
|
|
2845
|
-
});
|
|
2846
|
-
this._listeners = [];
|
|
3074
|
+
// Always rebuild the binding map from current DOM
|
|
3075
|
+
const eventMap = new Map(); // event → [{ selector, methodExpr, modifiers, el }]
|
|
2847
3076
|
|
|
2848
|
-
// Find all elements with @event or z-on:event attributes
|
|
2849
3077
|
const allEls = this._el.querySelectorAll('*');
|
|
2850
|
-
const eventMap = new Map(); // event → [{ selector, method, modifiers }]
|
|
2851
|
-
|
|
2852
3078
|
allEls.forEach(child => {
|
|
2853
|
-
// Skip elements inside z-pre subtrees
|
|
2854
3079
|
if (child.closest('[z-pre]')) return;
|
|
2855
3080
|
|
|
2856
|
-
|
|
2857
|
-
|
|
3081
|
+
const attrs = child.attributes;
|
|
3082
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
3083
|
+
const attr = attrs[a];
|
|
2858
3084
|
let raw;
|
|
2859
|
-
if (attr.name.
|
|
2860
|
-
raw = attr.name.slice(1);
|
|
3085
|
+
if (attr.name.charCodeAt(0) === 64) { // '@'
|
|
3086
|
+
raw = attr.name.slice(1);
|
|
2861
3087
|
} else if (attr.name.startsWith('z-on:')) {
|
|
2862
|
-
raw = attr.name.slice(5);
|
|
3088
|
+
raw = attr.name.slice(5);
|
|
2863
3089
|
} else {
|
|
2864
|
-
|
|
3090
|
+
continue;
|
|
2865
3091
|
}
|
|
2866
3092
|
|
|
2867
3093
|
const parts = raw.split('.');
|
|
@@ -2877,12 +3103,45 @@ class Component {
|
|
|
2877
3103
|
|
|
2878
3104
|
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
2879
3105
|
eventMap.get(event).push({ selector, methodExpr, modifiers, el: child });
|
|
2880
|
-
}
|
|
3106
|
+
}
|
|
2881
3107
|
});
|
|
2882
3108
|
|
|
3109
|
+
// Store binding map for the delegated handlers to reference
|
|
3110
|
+
this._eventBindings = eventMap;
|
|
3111
|
+
|
|
3112
|
+
// Only attach DOM listeners once — reuse on subsequent renders.
|
|
3113
|
+
// The handlers close over `this` and read `this._eventBindings`
|
|
3114
|
+
// at fire time, so they always use the latest binding map.
|
|
3115
|
+
if (this._delegatedEvents) {
|
|
3116
|
+
// Already attached — just make sure new event types are covered
|
|
3117
|
+
for (const event of eventMap.keys()) {
|
|
3118
|
+
if (!this._delegatedEvents.has(event)) {
|
|
3119
|
+
this._attachDelegatedEvent(event, eventMap.get(event));
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
// Remove listeners for event types no longer in the template
|
|
3123
|
+
for (const event of this._delegatedEvents.keys()) {
|
|
3124
|
+
if (!eventMap.has(event)) {
|
|
3125
|
+
const { handler, opts } = this._delegatedEvents.get(event);
|
|
3126
|
+
this._el.removeEventListener(event, handler, opts);
|
|
3127
|
+
this._delegatedEvents.delete(event);
|
|
3128
|
+
// Also remove from _listeners array
|
|
3129
|
+
this._listeners = this._listeners.filter(l => l.event !== event);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
this._delegatedEvents = new Map();
|
|
3136
|
+
|
|
2883
3137
|
// Register delegated listeners on the component root
|
|
2884
3138
|
for (const [event, bindings] of eventMap) {
|
|
2885
|
-
|
|
3139
|
+
this._attachDelegatedEvent(event, bindings);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
// Attach a single delegated listener for an event type
|
|
3144
|
+
_attachDelegatedEvent(event, bindings) {
|
|
2886
3145
|
const needsCapture = bindings.some(b => b.modifiers.includes('capture'));
|
|
2887
3146
|
const needsPassive = bindings.some(b => b.modifiers.includes('passive'));
|
|
2888
3147
|
const listenerOpts = (needsCapture || needsPassive)
|
|
@@ -2890,7 +3149,9 @@ class Component {
|
|
|
2890
3149
|
: false;
|
|
2891
3150
|
|
|
2892
3151
|
const handler = (e) => {
|
|
2893
|
-
|
|
3152
|
+
// Read bindings from live map — always up to date after re-renders
|
|
3153
|
+
const currentBindings = this._eventBindings?.get(event) || [];
|
|
3154
|
+
for (const { selector, methodExpr, modifiers, el } of currentBindings) {
|
|
2894
3155
|
if (!e.target.closest(selector)) continue;
|
|
2895
3156
|
|
|
2896
3157
|
// .self — only fire if target is the element itself
|
|
@@ -2960,7 +3221,7 @@ class Component {
|
|
|
2960
3221
|
};
|
|
2961
3222
|
this._el.addEventListener(event, handler, listenerOpts);
|
|
2962
3223
|
this._listeners.push({ event, handler });
|
|
2963
|
-
|
|
3224
|
+
this._delegatedEvents.set(event, { handler, opts: listenerOpts });
|
|
2964
3225
|
}
|
|
2965
3226
|
|
|
2966
3227
|
// Bind z-ref="name" → this.refs.name
|
|
@@ -2999,7 +3260,7 @@ class Component {
|
|
|
2999
3260
|
// Read current state value (supports dot-path keys)
|
|
3000
3261
|
const currentVal = _getPath(this.state, key);
|
|
3001
3262
|
|
|
3002
|
-
// -- Set initial DOM value from state
|
|
3263
|
+
// -- Set initial DOM value from state (always sync) ----------
|
|
3003
3264
|
if (tag === 'input' && type === 'checkbox') {
|
|
3004
3265
|
el.checked = !!currentVal;
|
|
3005
3266
|
} else if (tag === 'input' && type === 'radio') {
|
|
@@ -3021,6 +3282,11 @@ class Component {
|
|
|
3021
3282
|
: isEditable ? 'input' : 'input';
|
|
3022
3283
|
|
|
3023
3284
|
// -- Handler: read DOM → write to reactive state -------------
|
|
3285
|
+
// Skip if already bound (morph preserves existing elements,
|
|
3286
|
+
// so re-binding would stack duplicate listeners)
|
|
3287
|
+
if (el._zqModelBound) return;
|
|
3288
|
+
el._zqModelBound = true;
|
|
3289
|
+
|
|
3024
3290
|
const handler = () => {
|
|
3025
3291
|
let val;
|
|
3026
3292
|
if (type === 'checkbox') val = el.checked;
|
|
@@ -3140,6 +3406,41 @@ class Component {
|
|
|
3140
3406
|
return temp.innerHTML;
|
|
3141
3407
|
}
|
|
3142
3408
|
|
|
3409
|
+
// ---------------------------------------------------------------------------
|
|
3410
|
+
// _expandContentDirectives — Pre-morph z-html & z-text expansion
|
|
3411
|
+
//
|
|
3412
|
+
// Evaluates z-html and z-text directives at the string level so the morph
|
|
3413
|
+
// engine receives HTML with the actual content inline. This lets the diff
|
|
3414
|
+
// algorithm properly compare old vs new content (text nodes, child elements)
|
|
3415
|
+
// instead of clearing + re-injecting on every re-render.
|
|
3416
|
+
//
|
|
3417
|
+
// Same parse → evaluate → serialize pattern as _expandZFor.
|
|
3418
|
+
// ---------------------------------------------------------------------------
|
|
3419
|
+
_expandContentDirectives(html) {
|
|
3420
|
+
if (!html.includes('z-html') && !html.includes('z-text')) return html;
|
|
3421
|
+
|
|
3422
|
+
const temp = document.createElement('div');
|
|
3423
|
+
temp.innerHTML = html;
|
|
3424
|
+
|
|
3425
|
+
// z-html: evaluate expression → inject as innerHTML
|
|
3426
|
+
temp.querySelectorAll('[z-html]').forEach(el => {
|
|
3427
|
+
if (el.closest('[z-pre]')) return;
|
|
3428
|
+
const val = this._evalExpr(el.getAttribute('z-html'));
|
|
3429
|
+
el.innerHTML = val != null ? String(val) : '';
|
|
3430
|
+
el.removeAttribute('z-html');
|
|
3431
|
+
});
|
|
3432
|
+
|
|
3433
|
+
// z-text: evaluate expression → inject as textContent (HTML-safe)
|
|
3434
|
+
temp.querySelectorAll('[z-text]').forEach(el => {
|
|
3435
|
+
if (el.closest('[z-pre]')) return;
|
|
3436
|
+
const val = this._evalExpr(el.getAttribute('z-text'));
|
|
3437
|
+
el.textContent = val != null ? String(val) : '';
|
|
3438
|
+
el.removeAttribute('z-text');
|
|
3439
|
+
});
|
|
3440
|
+
|
|
3441
|
+
return temp.innerHTML;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3143
3444
|
// ---------------------------------------------------------------------------
|
|
3144
3445
|
// _processDirectives — Post-innerHTML DOM-level directive processing
|
|
3145
3446
|
// ---------------------------------------------------------------------------
|
|
@@ -3192,25 +3493,36 @@ class Component {
|
|
|
3192
3493
|
});
|
|
3193
3494
|
|
|
3194
3495
|
// -- z-bind:attr / :attr (dynamic attribute binding) -----------
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3496
|
+
// Use TreeWalker instead of querySelectorAll('*') — avoids
|
|
3497
|
+
// creating a flat array of every single descendant element.
|
|
3498
|
+
// TreeWalker visits nodes lazily; FILTER_REJECT skips z-pre subtrees
|
|
3499
|
+
// at the walker level (faster than per-node closest('[z-pre]') checks).
|
|
3500
|
+
const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
|
|
3501
|
+
acceptNode(n) {
|
|
3502
|
+
return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
3503
|
+
}
|
|
3504
|
+
});
|
|
3505
|
+
let node;
|
|
3506
|
+
while ((node = walker.nextNode())) {
|
|
3507
|
+
const attrs = node.attributes;
|
|
3508
|
+
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
3509
|
+
const attr = attrs[i];
|
|
3198
3510
|
let attrName;
|
|
3199
3511
|
if (attr.name.startsWith('z-bind:')) attrName = attr.name.slice(7);
|
|
3200
|
-
else if (attr.name.
|
|
3201
|
-
else
|
|
3512
|
+
else if (attr.name.charCodeAt(0) === 58 && attr.name.charCodeAt(1) !== 58) attrName = attr.name.slice(1); // ':' but not '::'
|
|
3513
|
+
else continue;
|
|
3202
3514
|
|
|
3203
3515
|
const val = this._evalExpr(attr.value);
|
|
3204
|
-
|
|
3516
|
+
node.removeAttribute(attr.name);
|
|
3205
3517
|
if (val === false || val === null || val === undefined) {
|
|
3206
|
-
|
|
3518
|
+
node.removeAttribute(attrName);
|
|
3207
3519
|
} else if (val === true) {
|
|
3208
|
-
|
|
3520
|
+
node.setAttribute(attrName, '');
|
|
3209
3521
|
} else {
|
|
3210
|
-
|
|
3522
|
+
node.setAttribute(attrName, String(val));
|
|
3211
3523
|
}
|
|
3212
|
-
}
|
|
3213
|
-
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3214
3526
|
|
|
3215
3527
|
// -- z-class (dynamic class binding) ---------------------------
|
|
3216
3528
|
this._el.querySelectorAll('[z-class]').forEach(el => {
|
|
@@ -3242,21 +3554,9 @@ class Component {
|
|
|
3242
3554
|
el.removeAttribute('z-style');
|
|
3243
3555
|
});
|
|
3244
3556
|
|
|
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
|
-
});
|
|
3557
|
+
// z-html and z-text are now pre-expanded at string level (before
|
|
3558
|
+
// morph) via _expandContentDirectives(), so the diff engine can
|
|
3559
|
+
// properly diff their content instead of clearing + re-injecting.
|
|
3260
3560
|
|
|
3261
3561
|
// -- z-cloak (remove after render) -----------------------------
|
|
3262
3562
|
this._el.querySelectorAll('[z-cloak]').forEach(el => {
|
|
@@ -3289,6 +3589,8 @@ class Component {
|
|
|
3289
3589
|
}
|
|
3290
3590
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
3291
3591
|
this._listeners = [];
|
|
3592
|
+
this._delegatedEvents = null;
|
|
3593
|
+
this._eventBindings = null;
|
|
3292
3594
|
if (this._styleEl) this._styleEl.remove();
|
|
3293
3595
|
_instances.delete(this._el);
|
|
3294
3596
|
this._el.innerHTML = '';
|
|
@@ -3299,7 +3601,7 @@ class Component {
|
|
|
3299
3601
|
// Reserved definition keys (not user methods)
|
|
3300
3602
|
const _reservedKeys = new Set([
|
|
3301
3603
|
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
3302
|
-
'templateUrl', 'styleUrl', 'templates', '
|
|
3604
|
+
'templateUrl', 'styleUrl', 'templates', 'base',
|
|
3303
3605
|
'computed', 'watch'
|
|
3304
3606
|
]);
|
|
3305
3607
|
|
|
@@ -3322,8 +3624,8 @@ function component(name, definition) {
|
|
|
3322
3624
|
}
|
|
3323
3625
|
definition._name = name;
|
|
3324
3626
|
|
|
3325
|
-
// Auto-detect the calling module's URL so that relative templateUrl
|
|
3326
|
-
//
|
|
3627
|
+
// Auto-detect the calling module's URL so that relative templateUrl
|
|
3628
|
+
// and styleUrl paths resolve relative to the component file.
|
|
3327
3629
|
// An explicit `base` string on the definition overrides auto-detection.
|
|
3328
3630
|
if (definition.base !== undefined) {
|
|
3329
3631
|
definition._base = definition.base; // explicit override
|
|
@@ -3439,6 +3741,24 @@ function getRegistry() {
|
|
|
3439
3741
|
return Object.fromEntries(_registry);
|
|
3440
3742
|
}
|
|
3441
3743
|
|
|
3744
|
+
/**
|
|
3745
|
+
* Pre-load a component's external templates and styles so the next mount
|
|
3746
|
+
* renders synchronously (no blank flash while fetching).
|
|
3747
|
+
* Safe to call multiple times — skips if already loaded.
|
|
3748
|
+
* @param {string} name — registered component name
|
|
3749
|
+
* @returns {Promise<void>}
|
|
3750
|
+
*/
|
|
3751
|
+
async function prefetch(name) {
|
|
3752
|
+
const def = _registry.get(name);
|
|
3753
|
+
if (!def) return;
|
|
3754
|
+
|
|
3755
|
+
// Load templateUrl and styleUrl if not already loaded.
|
|
3756
|
+
if ((def.templateUrl && !def._templateLoaded) ||
|
|
3757
|
+
(def.styleUrl && !def._styleLoaded)) {
|
|
3758
|
+
await Component.prototype._loadExternals.call({ _def: def });
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3442
3762
|
|
|
3443
3763
|
// ---------------------------------------------------------------------------
|
|
3444
3764
|
// Global stylesheet loader
|
|
@@ -3546,6 +3866,7 @@ function style(urls, opts = {}) {
|
|
|
3546
3866
|
*
|
|
3547
3867
|
* Supports hash mode (#/path) and history mode (/path).
|
|
3548
3868
|
* Route params, query strings, navigation guards, and lazy loading.
|
|
3869
|
+
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
3549
3870
|
*
|
|
3550
3871
|
* Usage:
|
|
3551
3872
|
* $.router({
|
|
@@ -3562,6 +3883,9 @@ function style(urls, opts = {}) {
|
|
|
3562
3883
|
|
|
3563
3884
|
|
|
3564
3885
|
|
|
3886
|
+
// Unique marker on history.state to identify zQuery-managed entries
|
|
3887
|
+
const _ZQ_STATE_KEY = '__zq';
|
|
3888
|
+
|
|
3565
3889
|
class Router {
|
|
3566
3890
|
constructor(config = {}) {
|
|
3567
3891
|
this._el = null;
|
|
@@ -3595,6 +3919,10 @@ class Router {
|
|
|
3595
3919
|
this._instance = null; // current mounted component
|
|
3596
3920
|
this._resolving = false; // re-entrancy guard
|
|
3597
3921
|
|
|
3922
|
+
// Sub-route history substates
|
|
3923
|
+
this._substateListeners = []; // [(key, data) => bool|void]
|
|
3924
|
+
this._inSubstate = false; // true while substate entries are in the history stack
|
|
3925
|
+
|
|
3598
3926
|
// Set outlet element
|
|
3599
3927
|
if (config.el) {
|
|
3600
3928
|
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
@@ -3609,7 +3937,21 @@ class Router {
|
|
|
3609
3937
|
if (this._mode === 'hash') {
|
|
3610
3938
|
window.addEventListener('hashchange', () => this._resolve());
|
|
3611
3939
|
} else {
|
|
3612
|
-
window.addEventListener('popstate', () =>
|
|
3940
|
+
window.addEventListener('popstate', (e) => {
|
|
3941
|
+
// Check for substate pop first — if a listener handles it, don't route
|
|
3942
|
+
const st = e.state;
|
|
3943
|
+
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
3944
|
+
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
3945
|
+
if (handled) return;
|
|
3946
|
+
// Unhandled substate — fall through to route resolve
|
|
3947
|
+
// _inSubstate stays true so the next non-substate pop triggers reset
|
|
3948
|
+
} else if (this._inSubstate) {
|
|
3949
|
+
// Popped past all substates — notify listeners to reset to defaults
|
|
3950
|
+
this._inSubstate = false;
|
|
3951
|
+
this._fireSubstate(null, null, 'reset');
|
|
3952
|
+
}
|
|
3953
|
+
this._resolve();
|
|
3954
|
+
});
|
|
3613
3955
|
}
|
|
3614
3956
|
|
|
3615
3957
|
// Intercept link clicks for SPA navigation
|
|
@@ -3658,7 +4000,7 @@ class Router {
|
|
|
3658
4000
|
|
|
3659
4001
|
// Per-route fallback: register an alias path for the same component.
|
|
3660
4002
|
// e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
|
|
3661
|
-
// When matched via fallback, missing params are undefined
|
|
4003
|
+
// When matched via fallback, missing params are undefined.
|
|
3662
4004
|
if (route.fallback) {
|
|
3663
4005
|
const fbKeys = [];
|
|
3664
4006
|
const fbPattern = route.fallback
|
|
@@ -3692,6 +4034,19 @@ class Router {
|
|
|
3692
4034
|
});
|
|
3693
4035
|
}
|
|
3694
4036
|
|
|
4037
|
+
/**
|
|
4038
|
+
* Get the full current URL (path + hash) for same-URL detection.
|
|
4039
|
+
* @returns {string}
|
|
4040
|
+
*/
|
|
4041
|
+
_currentURL() {
|
|
4042
|
+
if (this._mode === 'hash') {
|
|
4043
|
+
return window.location.hash.slice(1) || '/';
|
|
4044
|
+
}
|
|
4045
|
+
const pathname = window.location.pathname || '/';
|
|
4046
|
+
const hash = window.location.hash || '';
|
|
4047
|
+
return pathname + hash;
|
|
4048
|
+
}
|
|
4049
|
+
|
|
3695
4050
|
navigate(path, options = {}) {
|
|
3696
4051
|
// Interpolate :param placeholders if options.params is provided
|
|
3697
4052
|
if (options.params) path = this._interpolateParams(path, options.params);
|
|
@@ -3703,9 +4058,48 @@ class Router {
|
|
|
3703
4058
|
// Hash mode uses the URL hash for routing, so a #fragment can't live
|
|
3704
4059
|
// in the URL. Store it as a scroll target for the destination component.
|
|
3705
4060
|
if (fragment) window.__zqScrollTarget = fragment;
|
|
3706
|
-
|
|
4061
|
+
const targetHash = '#' + normalized;
|
|
4062
|
+
// Skip if already at this exact hash (prevents duplicate entries)
|
|
4063
|
+
if (window.location.hash === targetHash && !options.force) return this;
|
|
4064
|
+
window.location.hash = targetHash;
|
|
3707
4065
|
} else {
|
|
3708
|
-
|
|
4066
|
+
const targetURL = this._base + normalized + hash;
|
|
4067
|
+
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
4068
|
+
|
|
4069
|
+
if (targetURL === currentURL && !options.force) {
|
|
4070
|
+
// Same full URL (path + hash) — don't push duplicate entry.
|
|
4071
|
+
// If only the hash changed to a fragment target, scroll to it.
|
|
4072
|
+
if (fragment) {
|
|
4073
|
+
const el = document.getElementById(fragment);
|
|
4074
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4075
|
+
}
|
|
4076
|
+
return this;
|
|
4077
|
+
}
|
|
4078
|
+
|
|
4079
|
+
// Same route path but different hash fragment — use replaceState
|
|
4080
|
+
// so back goes to the previous *route*, not the previous scroll position.
|
|
4081
|
+
const targetPathOnly = this._base + normalized;
|
|
4082
|
+
const currentPathOnly = window.location.pathname || '/';
|
|
4083
|
+
if (targetPathOnly === currentPathOnly && hash && !options.force) {
|
|
4084
|
+
window.history.replaceState(
|
|
4085
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4086
|
+
'',
|
|
4087
|
+
targetURL
|
|
4088
|
+
);
|
|
4089
|
+
// Scroll to the fragment target
|
|
4090
|
+
if (fragment) {
|
|
4091
|
+
const el = document.getElementById(fragment);
|
|
4092
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4093
|
+
}
|
|
4094
|
+
// Don't re-resolve — same route, just a hash change
|
|
4095
|
+
return this;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
window.history.pushState(
|
|
4099
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4100
|
+
'',
|
|
4101
|
+
targetURL
|
|
4102
|
+
);
|
|
3709
4103
|
this._resolve();
|
|
3710
4104
|
}
|
|
3711
4105
|
return this;
|
|
@@ -3721,7 +4115,11 @@ class Router {
|
|
|
3721
4115
|
if (fragment) window.__zqScrollTarget = fragment;
|
|
3722
4116
|
window.location.replace('#' + normalized);
|
|
3723
4117
|
} else {
|
|
3724
|
-
window.history.replaceState(
|
|
4118
|
+
window.history.replaceState(
|
|
4119
|
+
{ ...options.state, [_ZQ_STATE_KEY]: 'route' },
|
|
4120
|
+
'',
|
|
4121
|
+
this._base + normalized + hash
|
|
4122
|
+
);
|
|
3725
4123
|
this._resolve();
|
|
3726
4124
|
}
|
|
3727
4125
|
return this;
|
|
@@ -3776,6 +4174,80 @@ class Router {
|
|
|
3776
4174
|
return () => this._listeners.delete(fn);
|
|
3777
4175
|
}
|
|
3778
4176
|
|
|
4177
|
+
// --- Sub-route history substates -----------------------------------------
|
|
4178
|
+
|
|
4179
|
+
/**
|
|
4180
|
+
* Push a lightweight history entry for in-component UI state.
|
|
4181
|
+
* The URL path does NOT change — only a history entry is added so the
|
|
4182
|
+
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
4183
|
+
* before navigating away.
|
|
4184
|
+
*
|
|
4185
|
+
* @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
|
|
4186
|
+
* @param {*} data — arbitrary state (serializable)
|
|
4187
|
+
* @returns {Router}
|
|
4188
|
+
*
|
|
4189
|
+
* @example
|
|
4190
|
+
* // Open a modal and push a substate
|
|
4191
|
+
* router.pushSubstate('modal', { id: 'confirm-delete' });
|
|
4192
|
+
* // User hits back → onSubstate fires → close the modal
|
|
4193
|
+
*/
|
|
4194
|
+
pushSubstate(key, data) {
|
|
4195
|
+
this._inSubstate = true;
|
|
4196
|
+
if (this._mode === 'hash') {
|
|
4197
|
+
// Hash mode: stash the substate in a global — hashchange will check.
|
|
4198
|
+
// We still push a history entry via a sentinel hash suffix.
|
|
4199
|
+
const current = window.location.hash || '#/';
|
|
4200
|
+
window.history.pushState(
|
|
4201
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
4202
|
+
'',
|
|
4203
|
+
window.location.href
|
|
4204
|
+
);
|
|
4205
|
+
} else {
|
|
4206
|
+
window.history.pushState(
|
|
4207
|
+
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
4208
|
+
'',
|
|
4209
|
+
window.location.href // keep same URL
|
|
4210
|
+
);
|
|
4211
|
+
}
|
|
4212
|
+
return this;
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
/**
|
|
4216
|
+
* Register a listener for substate pops (back button on a substate entry).
|
|
4217
|
+
* The callback receives `(key, data)` and should return `true` if it
|
|
4218
|
+
* handled the pop (prevents route resolution). If no listener returns
|
|
4219
|
+
* `true`, normal route resolution proceeds.
|
|
4220
|
+
*
|
|
4221
|
+
* @param {(key: string, data: any, action: string) => boolean|void} fn
|
|
4222
|
+
* @returns {() => void} unsubscribe function
|
|
4223
|
+
*
|
|
4224
|
+
* @example
|
|
4225
|
+
* const unsub = router.onSubstate((key, data) => {
|
|
4226
|
+
* if (key === 'modal') { closeModal(); return true; }
|
|
4227
|
+
* });
|
|
4228
|
+
*/
|
|
4229
|
+
onSubstate(fn) {
|
|
4230
|
+
this._substateListeners.push(fn);
|
|
4231
|
+
return () => {
|
|
4232
|
+
this._substateListeners = this._substateListeners.filter(f => f !== fn);
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
/**
|
|
4237
|
+
* Fire substate listeners. Returns true if any listener handled it.
|
|
4238
|
+
* @private
|
|
4239
|
+
*/
|
|
4240
|
+
_fireSubstate(key, data, action) {
|
|
4241
|
+
for (const fn of this._substateListeners) {
|
|
4242
|
+
try {
|
|
4243
|
+
if (fn(key, data, action) === true) return true;
|
|
4244
|
+
} catch (err) {
|
|
4245
|
+
reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
return false;
|
|
4249
|
+
}
|
|
4250
|
+
|
|
3779
4251
|
// --- Current state -------------------------------------------------------
|
|
3780
4252
|
|
|
3781
4253
|
get current() { return this._current; }
|
|
@@ -3836,6 +4308,16 @@ class Router {
|
|
|
3836
4308
|
}
|
|
3837
4309
|
|
|
3838
4310
|
async __resolve() {
|
|
4311
|
+
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
4312
|
+
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
4313
|
+
// if handled — the URL hasn't changed so there's no route to resolve.
|
|
4314
|
+
const histState = window.history.state;
|
|
4315
|
+
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
4316
|
+
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
4317
|
+
if (handled) return;
|
|
4318
|
+
// No listener handled it — fall through to normal routing
|
|
4319
|
+
}
|
|
4320
|
+
|
|
3839
4321
|
const fullPath = this.path;
|
|
3840
4322
|
const [pathPart, queryString] = fullPath.split('?');
|
|
3841
4323
|
const path = pathPart || '/';
|
|
@@ -3863,6 +4345,18 @@ class Router {
|
|
|
3863
4345
|
const to = { route: matched, params, query, path };
|
|
3864
4346
|
const from = this._current;
|
|
3865
4347
|
|
|
4348
|
+
// Same-route optimization: if the resolved route is the same component
|
|
4349
|
+
// with the same params, skip the full destroy/mount cycle and just
|
|
4350
|
+
// update props. This prevents flashing and unnecessary DOM churn.
|
|
4351
|
+
if (from && this._instance && matched.component === from.route.component) {
|
|
4352
|
+
const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
|
|
4353
|
+
const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
|
|
4354
|
+
if (sameParams && sameQuery) {
|
|
4355
|
+
// Identical navigation — nothing to do
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
|
|
3866
4360
|
// Run before guards
|
|
3867
4361
|
for (const guard of this._guards.before) {
|
|
3868
4362
|
try {
|
|
@@ -3881,7 +4375,11 @@ class Router {
|
|
|
3881
4375
|
if (rFrag) window.__zqScrollTarget = rFrag;
|
|
3882
4376
|
window.location.replace('#' + rNorm);
|
|
3883
4377
|
} else {
|
|
3884
|
-
window.history.replaceState(
|
|
4378
|
+
window.history.replaceState(
|
|
4379
|
+
{ [_ZQ_STATE_KEY]: 'route' },
|
|
4380
|
+
'',
|
|
4381
|
+
this._base + rNorm + rHash
|
|
4382
|
+
);
|
|
3885
4383
|
}
|
|
3886
4384
|
return this.__resolve();
|
|
3887
4385
|
}
|
|
@@ -3904,6 +4402,12 @@ class Router {
|
|
|
3904
4402
|
|
|
3905
4403
|
// Mount component into outlet
|
|
3906
4404
|
if (this._el && matched.component) {
|
|
4405
|
+
// Pre-load external templates/styles so the mount renders synchronously
|
|
4406
|
+
// (keeps old content visible during the fetch instead of showing blank)
|
|
4407
|
+
if (typeof matched.component === 'string') {
|
|
4408
|
+
await prefetch(matched.component);
|
|
4409
|
+
}
|
|
4410
|
+
|
|
3907
4411
|
// Destroy previous
|
|
3908
4412
|
if (this._instance) {
|
|
3909
4413
|
this._instance.destroy();
|
|
@@ -3911,6 +4415,7 @@ class Router {
|
|
|
3911
4415
|
}
|
|
3912
4416
|
|
|
3913
4417
|
// Create container
|
|
4418
|
+
const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
|
|
3914
4419
|
this._el.innerHTML = '';
|
|
3915
4420
|
|
|
3916
4421
|
// Pass route params and query as props
|
|
@@ -3921,10 +4426,12 @@ class Router {
|
|
|
3921
4426
|
const container = document.createElement(matched.component);
|
|
3922
4427
|
this._el.appendChild(container);
|
|
3923
4428
|
this._instance = mount(container, matched.component, props);
|
|
4429
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
|
|
3924
4430
|
}
|
|
3925
4431
|
// If component is a render function
|
|
3926
4432
|
else if (typeof matched.component === 'function') {
|
|
3927
4433
|
this._el.innerHTML = matched.component(to);
|
|
4434
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
|
|
3928
4435
|
}
|
|
3929
4436
|
}
|
|
3930
4437
|
|
|
@@ -3942,6 +4449,8 @@ class Router {
|
|
|
3942
4449
|
destroy() {
|
|
3943
4450
|
if (this._instance) this._instance.destroy();
|
|
3944
4451
|
this._listeners.clear();
|
|
4452
|
+
this._substateListeners = [];
|
|
4453
|
+
this._inSubstate = false;
|
|
3945
4454
|
this._routes = [];
|
|
3946
4455
|
this._guards = { before: [], after: [] };
|
|
3947
4456
|
}
|
|
@@ -4704,8 +5213,10 @@ $.mountAll = mountAll;
|
|
|
4704
5213
|
$.getInstance = getInstance;
|
|
4705
5214
|
$.destroy = destroy;
|
|
4706
5215
|
$.components = getRegistry;
|
|
5216
|
+
$.prefetch = prefetch;
|
|
4707
5217
|
$.style = style;
|
|
4708
|
-
$.morph
|
|
5218
|
+
$.morph = morph;
|
|
5219
|
+
$.morphElement = morphElement;
|
|
4709
5220
|
$.safeEval = safeEval;
|
|
4710
5221
|
|
|
4711
5222
|
// --- Router ----------------------------------------------------------------
|
|
@@ -4746,12 +5257,15 @@ $.session = session;
|
|
|
4746
5257
|
$.bus = bus;
|
|
4747
5258
|
|
|
4748
5259
|
// --- Error handling --------------------------------------------------------
|
|
4749
|
-
$.onError
|
|
4750
|
-
$.ZQueryError
|
|
4751
|
-
$.ErrorCode
|
|
5260
|
+
$.onError = onError;
|
|
5261
|
+
$.ZQueryError = ZQueryError;
|
|
5262
|
+
$.ErrorCode = ErrorCode;
|
|
5263
|
+
$.guardCallback = guardCallback;
|
|
5264
|
+
$.validate = validate;
|
|
4752
5265
|
|
|
4753
5266
|
// --- Meta ------------------------------------------------------------------
|
|
4754
|
-
$.version = '0.7
|
|
5267
|
+
$.version = '0.8.7';
|
|
5268
|
+
$.libSize = '~89 KB';
|
|
4755
5269
|
$.meta = {}; // populated at build time by CLI bundler
|
|
4756
5270
|
|
|
4757
5271
|
$.noConflict = () => {
|