zero-query 1.2.0 → 1.2.3
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/cli/commands/build-api.js +0 -1
- package/cli/commands/build.js +0 -1
- package/cli/commands/bundle.js +0 -2
- package/cli/commands/create.js +46 -1
- package/cli/scaffold/webrtc/app/components/video-room.js +276 -130
- package/cli/scaffold/webrtc/package.json +16 -0
- package/cli/scaffold/webrtc/server/index.js +198 -0
- package/dist/API.md +1 -1
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +296 -155
- package/dist/zquery.min.js +7 -7
- package/package.json +3 -2
- package/src/component.js +161 -112
- package/src/core.js +11 -3
- package/src/expression.js +26 -19
- package/src/reactive.js +18 -2
- package/src/router.js +38 -3
- package/src/ssr.js +14 -11
- package/src/store.js +16 -9
- package/src/utils.js +24 -5
- package/tests/audit.test.js +9 -9
- package/tests/bench/render.bench.js +77 -0
- package/tests/cli.test.js +7 -7
- package/tests/component.test.js +107 -0
- package/tests/core.test.js +16 -0
- package/tests/expression.test.js +31 -0
- package/tests/http.test.js +45 -0
- package/tests/reactive.test.js +25 -0
- package/tests/router.test.js +96 -0
- package/tests/ssr.test.js +46 -0
- package/tests/store.test.js +10 -0
- package/tests/utils.test.js +34 -0
- package/cli/scaffold/webrtc/app/lib/room.js +0 -252
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-query",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Lightweight modern frontend library - jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"lint:fix": "eslint . --fix",
|
|
36
36
|
"test": "vitest run",
|
|
37
37
|
"test:watch": "vitest",
|
|
38
|
-
"test:ssr": "node tests/test-ssr.js"
|
|
38
|
+
"test:ssr": "node tests/test-ssr.js",
|
|
39
|
+
"bench": "vitest bench --run"
|
|
39
40
|
},
|
|
40
41
|
"keywords": [
|
|
41
42
|
"dom",
|
package/src/component.js
CHANGED
|
@@ -190,6 +190,10 @@ class Component {
|
|
|
190
190
|
this._updateQueued = false;
|
|
191
191
|
this._listeners = [];
|
|
192
192
|
this._watchCleanups = [];
|
|
193
|
+
// Elements that have scheduled debounce/throttle timers under this
|
|
194
|
+
// component instance. Tracked so destroy() can clear them even if the
|
|
195
|
+
// element is detached from the subtree before teardown.
|
|
196
|
+
this._timerEls = new Set();
|
|
193
197
|
|
|
194
198
|
// Refs map
|
|
195
199
|
this.refs = {};
|
|
@@ -214,15 +218,21 @@ class Component {
|
|
|
214
218
|
if (definition.props && typeof definition.props === 'object' && !Array.isArray(definition.props)) {
|
|
215
219
|
// Reactive props with type coercion and defaults
|
|
216
220
|
this.props = this._resolveReactiveProps(definition.props, props);
|
|
217
|
-
// MutationObserver to re-read props when parent re-renders and changes attributes
|
|
221
|
+
// MutationObserver to re-read props when parent re-renders and changes attributes.
|
|
222
|
+
// attributeFilter limits callbacks to the declared prop names (plus their
|
|
223
|
+
// dynamic `:name` aliases) — unrelated attribute churn no longer fires.
|
|
224
|
+
const propNames = Object.keys(definition.props);
|
|
225
|
+
const filter = [];
|
|
226
|
+
for (const n of propNames) {
|
|
227
|
+
filter.push(n.toLowerCase());
|
|
228
|
+
filter.push(':' + n.toLowerCase());
|
|
229
|
+
}
|
|
218
230
|
this._propObserver = new MutationObserver((mutations) => {
|
|
219
231
|
if (this._destroyed) return;
|
|
220
232
|
let changed = false;
|
|
221
233
|
for (const mut of mutations) {
|
|
222
234
|
if (mut.type === 'attributes') {
|
|
223
235
|
const attrName = mut.attributeName;
|
|
224
|
-
// Skip internal attributes
|
|
225
|
-
if (attrName.startsWith('z-') || attrName.startsWith('@') || attrName.startsWith(':') || attrName.startsWith('data-zq')) continue;
|
|
226
236
|
// Check if this is a defined prop (attribute names are lowercase)
|
|
227
237
|
const propName = attrName.startsWith(':') ? attrName.slice(1) : attrName;
|
|
228
238
|
if (propName in definition.props) {
|
|
@@ -235,7 +245,7 @@ class Component {
|
|
|
235
245
|
this._scheduleUpdate();
|
|
236
246
|
}
|
|
237
247
|
});
|
|
238
|
-
this._propObserver.observe(el, { attributes: true });
|
|
248
|
+
this._propObserver.observe(el, { attributes: true, attributeFilter: filter });
|
|
239
249
|
} else {
|
|
240
250
|
// Legacy: frozen props from parent
|
|
241
251
|
this.props = Object.freeze({ ...props });
|
|
@@ -517,44 +527,60 @@ class Component {
|
|
|
517
527
|
|
|
518
528
|
// Apply scoped styles on first render
|
|
519
529
|
if (!this._mounted && combinedStyles) {
|
|
520
|
-
const
|
|
530
|
+
const def = this._def;
|
|
531
|
+
// Use a stable per-definition scope attr so all instances share one
|
|
532
|
+
// <style> tag (ref-counted). Anonymous defs fall back to per-instance
|
|
533
|
+
// scoping for safety.
|
|
534
|
+
let scopeAttr = def._scopeAttr;
|
|
535
|
+
if (!scopeAttr) {
|
|
536
|
+
scopeAttr = def._name ? `z-s-${def._name}` : `z-s${this._uid}`;
|
|
537
|
+
def._scopeAttr = scopeAttr;
|
|
538
|
+
}
|
|
521
539
|
this._el.setAttribute(scopeAttr, '');
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
540
|
+
this._scopeAttr = scopeAttr;
|
|
541
|
+
|
|
542
|
+
if (def._styleEl && def._styleEl.isConnected) {
|
|
543
|
+
// Already injected by a previous instance — just bump refcount.
|
|
544
|
+
def._styleRefCount = (def._styleRefCount || 0) + 1;
|
|
545
|
+
} else {
|
|
546
|
+
let noScopeDepth = 0; // brace depth at which a no-scope @-rule started (0 = none active)
|
|
547
|
+
let braceDepth = 0; // overall brace depth
|
|
548
|
+
const scoped = combinedStyles.replace(/([^{}]+)\{|\}/g, (match, selector) => {
|
|
549
|
+
if (match === '}') {
|
|
550
|
+
if (noScopeDepth > 0 && braceDepth <= noScopeDepth) noScopeDepth = 0;
|
|
551
|
+
braceDepth--;
|
|
552
|
+
return match;
|
|
553
|
+
}
|
|
554
|
+
braceDepth++;
|
|
555
|
+
const trimmed = selector.trim();
|
|
556
|
+
// Don't scope @-rules themselves
|
|
557
|
+
if (trimmed.startsWith('@')) {
|
|
558
|
+
// @keyframes and @font-face contain non-selector content - skip scoping inside them
|
|
559
|
+
if (/^@(keyframes|font-face)\b/.test(trimmed)) {
|
|
560
|
+
noScopeDepth = braceDepth;
|
|
561
|
+
}
|
|
562
|
+
return match;
|
|
563
|
+
}
|
|
564
|
+
// Inside @keyframes or @font-face - don't scope inner rules
|
|
565
|
+
if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
|
|
566
|
+
return match;
|
|
567
|
+
}
|
|
568
|
+
return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
|
|
569
|
+
});
|
|
570
|
+
const styleEl = document.createElement('style');
|
|
571
|
+
styleEl.textContent = scoped;
|
|
572
|
+
styleEl.setAttribute('data-zq-component', def._name || '');
|
|
573
|
+
styleEl.setAttribute('data-zq-scope', scopeAttr);
|
|
574
|
+
if (def._resolvedStyleUrls) {
|
|
575
|
+
styleEl.setAttribute('data-zq-style-urls', def._resolvedStyleUrls.join(' '));
|
|
576
|
+
if (def.styles) {
|
|
577
|
+
styleEl.setAttribute('data-zq-inline', def.styles);
|
|
537
578
|
}
|
|
538
|
-
return match;
|
|
539
|
-
}
|
|
540
|
-
// Inside @keyframes or @font-face - don't scope inner rules
|
|
541
|
-
if (noScopeDepth > 0 && braceDepth > noScopeDepth) {
|
|
542
|
-
return match;
|
|
543
|
-
}
|
|
544
|
-
return selector.split(',').map(s => `[${scopeAttr}] ${s.trim()}`).join(', ') + ' {';
|
|
545
|
-
});
|
|
546
|
-
const styleEl = document.createElement('style');
|
|
547
|
-
styleEl.textContent = scoped;
|
|
548
|
-
styleEl.setAttribute('data-zq-component', this._def._name || '');
|
|
549
|
-
styleEl.setAttribute('data-zq-scope', scopeAttr);
|
|
550
|
-
if (this._def._resolvedStyleUrls) {
|
|
551
|
-
styleEl.setAttribute('data-zq-style-urls', this._def._resolvedStyleUrls.join(' '));
|
|
552
|
-
if (this._def.styles) {
|
|
553
|
-
styleEl.setAttribute('data-zq-inline', this._def.styles);
|
|
554
579
|
}
|
|
580
|
+
document.head.appendChild(styleEl);
|
|
581
|
+
def._styleEl = styleEl;
|
|
582
|
+
def._styleRefCount = 1;
|
|
555
583
|
}
|
|
556
|
-
document.head.appendChild(styleEl);
|
|
557
|
-
this._styleEl = styleEl;
|
|
558
584
|
}
|
|
559
585
|
|
|
560
586
|
// -- Focus preservation ----------------------------------------
|
|
@@ -763,7 +789,7 @@ class Component {
|
|
|
763
789
|
});
|
|
764
790
|
|
|
765
791
|
let stoppedAt = null; // Track elements that called .stop
|
|
766
|
-
for (const {
|
|
792
|
+
for (const { methodExpr, modifiers, el, matched } of hits) {
|
|
767
793
|
|
|
768
794
|
// In delegated events, .stop should prevent ancestor bindings from
|
|
769
795
|
// firing - stopPropagation alone only stops real DOM bubbling.
|
|
@@ -857,6 +883,7 @@ class Component {
|
|
|
857
883
|
clearTimeout(timers[event]);
|
|
858
884
|
timers[event] = setTimeout(() => invoke(e), ms);
|
|
859
885
|
_debounceTimers.set(el, timers);
|
|
886
|
+
this._timerEls.add(el);
|
|
860
887
|
continue;
|
|
861
888
|
}
|
|
862
889
|
|
|
@@ -869,6 +896,7 @@ class Component {
|
|
|
869
896
|
invoke(e);
|
|
870
897
|
timers[event] = setTimeout(() => { timers[event] = null; }, ms);
|
|
871
898
|
_throttleTimers.set(el, timers);
|
|
899
|
+
this._timerEls.add(el);
|
|
872
900
|
continue;
|
|
873
901
|
}
|
|
874
902
|
|
|
@@ -951,10 +979,15 @@ class Component {
|
|
|
951
979
|
: isEditable ? 'input' : 'input';
|
|
952
980
|
|
|
953
981
|
// -- Handler: read DOM → write to reactive state -------------
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
|
|
957
|
-
|
|
982
|
+
// Remove any previously-bound z-model listener on this element so
|
|
983
|
+
// re-runs (re-render, keyed reconciliation, modifier change) replace
|
|
984
|
+
// instead of stacking. A boolean "already bound" flag is not enough:
|
|
985
|
+
// if the bound state key / modifiers ever change, the stale handler
|
|
986
|
+
// would keep writing to the old key.
|
|
987
|
+
if (el._zqModelUnbind) {
|
|
988
|
+
try { el._zqModelUnbind(); } catch { /* ignore */ }
|
|
989
|
+
el._zqModelUnbind = null;
|
|
990
|
+
}
|
|
958
991
|
|
|
959
992
|
const handler = () => {
|
|
960
993
|
let val;
|
|
@@ -976,12 +1009,18 @@ class Component {
|
|
|
976
1009
|
|
|
977
1010
|
if (hasDebounce) {
|
|
978
1011
|
let timer = null;
|
|
979
|
-
|
|
1012
|
+
const debounced = () => {
|
|
980
1013
|
clearTimeout(timer);
|
|
981
1014
|
timer = setTimeout(handler, debounceMs);
|
|
982
|
-
}
|
|
1015
|
+
};
|
|
1016
|
+
el.addEventListener(event, debounced);
|
|
1017
|
+
el._zqModelUnbind = () => {
|
|
1018
|
+
el.removeEventListener(event, debounced);
|
|
1019
|
+
clearTimeout(timer);
|
|
1020
|
+
};
|
|
983
1021
|
} else {
|
|
984
1022
|
el.addEventListener(event, handler);
|
|
1023
|
+
el._zqModelUnbind = () => el.removeEventListener(event, handler);
|
|
985
1024
|
}
|
|
986
1025
|
});
|
|
987
1026
|
}
|
|
@@ -1206,19 +1245,20 @@ class Component {
|
|
|
1206
1245
|
}
|
|
1207
1246
|
});
|
|
1208
1247
|
|
|
1209
|
-
// -- z-bind
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
// at the walker level (faster than per-node closest('[z-pre]') checks).
|
|
1248
|
+
// -- Single TreeWalker pass for z-bind/:attr, z-class, z-style,
|
|
1249
|
+
// z-stream, z-cloak. One walk amortises the cost across five
|
|
1250
|
+
// directives that used to issue separate querySelectorAll calls.
|
|
1251
|
+
// FILTER_REJECT on z-pre subtrees skips per-node closest() checks.
|
|
1214
1252
|
const walker = document.createTreeWalker(this._el, NodeFilter.SHOW_ELEMENT, {
|
|
1215
1253
|
acceptNode(n) {
|
|
1216
1254
|
return n.hasAttribute('z-pre') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
1217
1255
|
}
|
|
1218
1256
|
});
|
|
1257
|
+
const hasMediaStream = typeof MediaStream !== 'undefined';
|
|
1219
1258
|
let node;
|
|
1220
1259
|
while ((node = walker.nextNode())) {
|
|
1221
1260
|
const attrs = node.attributes;
|
|
1261
|
+
// z-bind:* / :* — iterate attrs once (reverse to allow remove)
|
|
1222
1262
|
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
1223
1263
|
const attr = attrs[i];
|
|
1224
1264
|
let attrName;
|
|
@@ -1229,71 +1269,67 @@ class Component {
|
|
|
1229
1269
|
const val = this._evalExpr(attr.value);
|
|
1230
1270
|
node.removeAttribute(attr.name);
|
|
1231
1271
|
if (val === false || val === null || val === undefined) {
|
|
1232
|
-
node.
|
|
1272
|
+
node.toggleAttribute(attrName, false);
|
|
1233
1273
|
} else if (val === true) {
|
|
1234
|
-
node.
|
|
1274
|
+
node.toggleAttribute(attrName, true);
|
|
1235
1275
|
} else {
|
|
1236
1276
|
node.setAttribute(attrName, String(val));
|
|
1237
1277
|
}
|
|
1238
1278
|
}
|
|
1239
|
-
}
|
|
1240
1279
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
val
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1280
|
+
// z-class
|
|
1281
|
+
if (node.hasAttribute('z-class')) {
|
|
1282
|
+
const val = this._evalExpr(node.getAttribute('z-class'));
|
|
1283
|
+
if (typeof val === 'string') {
|
|
1284
|
+
val.split(/\s+/).filter(Boolean).forEach(c => node.classList.add(c));
|
|
1285
|
+
} else if (Array.isArray(val)) {
|
|
1286
|
+
val.filter(Boolean).forEach(c => node.classList.add(String(c)));
|
|
1287
|
+
} else if (val && typeof val === 'object') {
|
|
1288
|
+
for (const [cls, active] of Object.entries(val)) {
|
|
1289
|
+
node.classList.toggle(cls, !!active);
|
|
1290
|
+
}
|
|
1252
1291
|
}
|
|
1292
|
+
node.removeAttribute('z-class');
|
|
1253
1293
|
}
|
|
1254
|
-
el.removeAttribute('z-class');
|
|
1255
|
-
});
|
|
1256
1294
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1295
|
+
// z-style
|
|
1296
|
+
if (node.hasAttribute('z-style')) {
|
|
1297
|
+
const val = this._evalExpr(node.getAttribute('z-style'));
|
|
1298
|
+
if (typeof val === 'string') {
|
|
1299
|
+
node.style.cssText += ';' + val;
|
|
1300
|
+
} else if (val && typeof val === 'object') {
|
|
1301
|
+
for (const [prop, v] of Object.entries(val)) {
|
|
1302
|
+
node.style[prop] = v;
|
|
1303
|
+
}
|
|
1266
1304
|
}
|
|
1305
|
+
node.removeAttribute('z-style');
|
|
1267
1306
|
}
|
|
1268
|
-
el.removeAttribute('z-style');
|
|
1269
|
-
});
|
|
1270
1307
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1308
|
+
// z-stream → MediaStream → <video>/<audio>.srcObject
|
|
1309
|
+
if (node.hasAttribute('z-stream')) {
|
|
1310
|
+
const val = this._evalExpr(node.getAttribute('z-stream'));
|
|
1311
|
+
if (val == null) {
|
|
1312
|
+
node.srcObject = null;
|
|
1313
|
+
} else if (hasMediaStream && val instanceof MediaStream) {
|
|
1314
|
+
node.srcObject = val;
|
|
1315
|
+
} else if (val && typeof val.getTracks === 'function') {
|
|
1316
|
+
// Accept duck-typed stream objects (test fakes, polyfills).
|
|
1317
|
+
node.srcObject = val;
|
|
1318
|
+
} else {
|
|
1319
|
+
node.srcObject = null;
|
|
1320
|
+
}
|
|
1321
|
+
node.removeAttribute('z-stream');
|
|
1285
1322
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1323
|
+
|
|
1324
|
+
// z-cloak (just strip)
|
|
1325
|
+
if (node.hasAttribute('z-cloak')) {
|
|
1326
|
+
node.removeAttribute('z-cloak');
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1288
1329
|
|
|
1289
1330
|
// z-html and z-text are now pre-expanded at string level (before
|
|
1290
1331
|
// morph) via _expandContentDirectives(), so the diff engine can
|
|
1291
1332
|
// properly diff their content instead of clearing + re-injecting.
|
|
1292
|
-
|
|
1293
|
-
// -- z-cloak (remove after render) -----------------------------
|
|
1294
|
-
this._el.querySelectorAll('[z-cloak]').forEach(el => {
|
|
1295
|
-
el.removeAttribute('z-cloak');
|
|
1296
|
-
});
|
|
1297
1333
|
}
|
|
1298
1334
|
|
|
1299
1335
|
// ---------------------------------------------------------------------------
|
|
@@ -1425,21 +1461,34 @@ class Component {
|
|
|
1425
1461
|
this._delegatedEvents = null;
|
|
1426
1462
|
this._eventBindings = null;
|
|
1427
1463
|
// Clear any pending debounce/throttle timers to prevent stale closures.
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1464
|
+
// Use the per-instance _timerEls set so detached elements (removed by
|
|
1465
|
+
// z-if / z-for / keyed reconciliation before destroy) are still cleaned.
|
|
1466
|
+
if (this._timerEls) {
|
|
1467
|
+
this._timerEls.forEach(child => {
|
|
1468
|
+
const dTimers = _debounceTimers.get(child);
|
|
1469
|
+
if (dTimers) {
|
|
1470
|
+
for (const key in dTimers) clearTimeout(dTimers[key]);
|
|
1471
|
+
_debounceTimers.delete(child);
|
|
1472
|
+
}
|
|
1473
|
+
const tTimers = _throttleTimers.get(child);
|
|
1474
|
+
if (tTimers) {
|
|
1475
|
+
for (const key in tTimers) clearTimeout(tTimers[key]);
|
|
1476
|
+
_throttleTimers.delete(child);
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
this._timerEls.clear();
|
|
1480
|
+
}
|
|
1481
|
+
if (this._scopeAttr) {
|
|
1482
|
+
const def = this._def;
|
|
1483
|
+
if (def && def._styleEl && def._scopeAttr === this._scopeAttr) {
|
|
1484
|
+
def._styleRefCount = (def._styleRefCount || 1) - 1;
|
|
1485
|
+
if (def._styleRefCount <= 0) {
|
|
1486
|
+
def._styleEl.remove();
|
|
1487
|
+
def._styleEl = null;
|
|
1488
|
+
def._styleRefCount = 0;
|
|
1489
|
+
}
|
|
1440
1490
|
}
|
|
1441
|
-
}
|
|
1442
|
-
if (this._styleEl) this._styleEl.remove();
|
|
1491
|
+
}
|
|
1443
1492
|
_instances.delete(this._el);
|
|
1444
1493
|
this._el.innerHTML = '';
|
|
1445
1494
|
}
|
package/src/core.js
CHANGED
|
@@ -283,11 +283,17 @@ export class ZQueryCollection {
|
|
|
283
283
|
});
|
|
284
284
|
}
|
|
285
285
|
if (value === undefined) return this.first()?.getAttribute(name);
|
|
286
|
-
|
|
286
|
+
// Fast path: tight loop, no closure allocation, no `each` overhead.
|
|
287
|
+
// Mirrors the addClass/removeClass single-name fast path.
|
|
288
|
+
const els = this.elements;
|
|
289
|
+
for (let i = 0; i < els.length; i++) els[i].setAttribute(name, value);
|
|
290
|
+
return this;
|
|
287
291
|
}
|
|
288
292
|
|
|
289
293
|
removeAttr(name) {
|
|
290
|
-
|
|
294
|
+
const els = this.elements;
|
|
295
|
+
for (let i = 0; i < els.length; i++) els[i].removeAttribute(name);
|
|
296
|
+
return this;
|
|
291
297
|
}
|
|
292
298
|
|
|
293
299
|
prop(name, value) {
|
|
@@ -852,7 +858,9 @@ export function queryAll(selector, context) {
|
|
|
852
858
|
// Quick-ref shortcuts, on $ namespace)
|
|
853
859
|
// ---------------------------------------------------------------------------
|
|
854
860
|
query.id = (id) => document.getElementById(id);
|
|
855
|
-
|
|
861
|
+
// Escape the class name so callers can safely pass identifiers containing
|
|
862
|
+
// dots, colons, leading digits, etc. without breaking the selector.
|
|
863
|
+
query.class = (name) => document.querySelector(`.${typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(name) : name}`);
|
|
856
864
|
query.classes = (name) => new ZQueryCollection(Array.from(document.getElementsByClassName(name)));
|
|
857
865
|
query.tag = (name) => new ZQueryCollection(Array.from(document.getElementsByTagName(name)));
|
|
858
866
|
Object.defineProperty(query, 'name', {
|
package/src/expression.js
CHANGED
|
@@ -218,12 +218,28 @@ class Parser {
|
|
|
218
218
|
|
|
219
219
|
// Main entry
|
|
220
220
|
parse() {
|
|
221
|
+
this.depth = 0;
|
|
221
222
|
const result = this.parseExpression(0);
|
|
222
223
|
return result;
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
// Precedence climbing
|
|
226
227
|
parseExpression(minPrec) {
|
|
228
|
+
// Cap nesting depth so a deeply parenthesised or recursive expression
|
|
229
|
+
// can’t stack-overflow the host. Threshold (96) is generous - real
|
|
230
|
+
// template expressions are flat - but stops pathological inputs cold.
|
|
231
|
+
if (++this.depth > 96) {
|
|
232
|
+
this.depth--;
|
|
233
|
+
throw new Error('Expression nesting depth exceeded (max 96)');
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return this._parseExpressionImpl(minPrec);
|
|
237
|
+
} finally {
|
|
238
|
+
this.depth--;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_parseExpressionImpl(minPrec) {
|
|
227
243
|
let left = this.parseUnary();
|
|
228
244
|
|
|
229
245
|
while (true) {
|
|
@@ -550,13 +566,6 @@ class Parser {
|
|
|
550
566
|
// ---------------------------------------------------------------------------
|
|
551
567
|
|
|
552
568
|
/** Safe property access whitelist for built-in prototypes */
|
|
553
|
-
const SAFE_ARRAY_METHODS = new Set([
|
|
554
|
-
'length', 'map', 'filter', 'find', 'findIndex', 'some', 'every',
|
|
555
|
-
'reduce', 'reduceRight', 'forEach', 'includes', 'indexOf', 'lastIndexOf',
|
|
556
|
-
'join', 'slice', 'concat', 'flat', 'flatMap', 'reverse', 'sort',
|
|
557
|
-
'fill', 'keys', 'values', 'entries', 'at', 'toString',
|
|
558
|
-
]);
|
|
559
|
-
|
|
560
569
|
const SAFE_STRING_METHODS = new Set([
|
|
561
570
|
'length', 'charAt', 'charCodeAt', 'includes', 'indexOf', 'lastIndexOf',
|
|
562
571
|
'slice', 'substring', 'trim', 'trimStart', 'trimEnd', 'toLowerCase',
|
|
@@ -569,18 +578,6 @@ const SAFE_NUMBER_METHODS = new Set([
|
|
|
569
578
|
'toFixed', 'toPrecision', 'toString', 'valueOf',
|
|
570
579
|
]);
|
|
571
580
|
|
|
572
|
-
const SAFE_OBJECT_METHODS = new Set([
|
|
573
|
-
'hasOwnProperty', 'toString', 'valueOf',
|
|
574
|
-
]);
|
|
575
|
-
|
|
576
|
-
const SAFE_MATH_PROPS = new Set([
|
|
577
|
-
'PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2',
|
|
578
|
-
'abs', 'ceil', 'floor', 'round', 'trunc', 'max', 'min', 'pow',
|
|
579
|
-
'sqrt', 'sign', 'random', 'log', 'log2', 'log10',
|
|
580
|
-
]);
|
|
581
|
-
|
|
582
|
-
const SAFE_JSON_PROPS = new Set(['parse', 'stringify']);
|
|
583
|
-
|
|
584
581
|
/**
|
|
585
582
|
* Check if property access is safe
|
|
586
583
|
*/
|
|
@@ -878,8 +875,18 @@ function _evalBinary(node, scope) {
|
|
|
878
875
|
const _astCache = new Map();
|
|
879
876
|
const _AST_CACHE_MAX = 512;
|
|
880
877
|
|
|
878
|
+
// Maximum source length accepted by safeEval. Real template expressions
|
|
879
|
+
// are tens of bytes; anything beyond 8 KB is almost certainly an attack
|
|
880
|
+
// payload or accidental dump. Reject up front to avoid feeding the
|
|
881
|
+
// tokenizer and parser unbounded input.
|
|
882
|
+
const _EXPR_MAX_LEN = 8192;
|
|
883
|
+
|
|
881
884
|
export function safeEval(expr, scope) {
|
|
882
885
|
try {
|
|
886
|
+
if (typeof expr !== 'string') return undefined;
|
|
887
|
+
if (expr.length > _EXPR_MAX_LEN) {
|
|
888
|
+
throw new Error(`Expression exceeds max length (${_EXPR_MAX_LEN} bytes)`);
|
|
889
|
+
}
|
|
883
890
|
const trimmed = expr.trim();
|
|
884
891
|
if (!trimmed) return undefined;
|
|
885
892
|
|
package/src/reactive.js
CHANGED
|
@@ -7,11 +7,27 @@
|
|
|
7
7
|
|
|
8
8
|
import { reportError, ErrorCode } from './errors.js';
|
|
9
9
|
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Host-object passthrough
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// The reactive proxy is meant for plain data: state bags, arrays, nested
|
|
14
|
+
// records. Wrapping host/native objects (MediaStream, RTCPeerConnection,
|
|
15
|
+
// Blob, Map, Date, etc.) breaks them - the proxy receiver is not the real
|
|
16
|
+
// underlying object, so any internal-slot method call throws
|
|
17
|
+
// "Illegal invocation" in the browser. Only proxy values whose prototype is
|
|
18
|
+
// Object.prototype/null (plain objects) or that are Arrays.
|
|
19
|
+
function _isProxyable(value) {
|
|
20
|
+
if (value === null || typeof value !== 'object') return false;
|
|
21
|
+
if (Array.isArray(value)) return true;
|
|
22
|
+
const proto = Object.getPrototypeOf(value);
|
|
23
|
+
return proto === Object.prototype || proto === null;
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
// ---------------------------------------------------------------------------
|
|
11
27
|
// Deep reactive proxy
|
|
12
28
|
// ---------------------------------------------------------------------------
|
|
13
29
|
export function reactive(target, onChange, _path = '') {
|
|
14
|
-
if (
|
|
30
|
+
if (!_isProxyable(target)) return target;
|
|
15
31
|
if (typeof onChange !== 'function') {
|
|
16
32
|
reportError(ErrorCode.REACTIVE_CALLBACK, 'reactive() onChange must be a function', { received: typeof onChange });
|
|
17
33
|
onChange = () => {};
|
|
@@ -25,7 +41,7 @@ export function reactive(target, onChange, _path = '') {
|
|
|
25
41
|
if (key === '__raw') return obj;
|
|
26
42
|
|
|
27
43
|
const value = obj[key];
|
|
28
|
-
if (
|
|
44
|
+
if (_isProxyable(value)) {
|
|
29
45
|
// Return cached proxy or create new one
|
|
30
46
|
if (proxyCache.has(value)) return proxyCache.get(value);
|
|
31
47
|
const childProxy = new Proxy(value, handler);
|
package/src/router.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* });
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { mount,
|
|
20
|
+
import { mount, prefetch } from './component.js';
|
|
21
21
|
import { reportError, ErrorCode } from './errors.js';
|
|
22
22
|
|
|
23
23
|
// Unique marker on history.state to identify zQuery-managed entries
|
|
@@ -48,7 +48,12 @@ class Router {
|
|
|
48
48
|
this._mode = isFile ? 'hash' : (config.mode || 'history');
|
|
49
49
|
|
|
50
50
|
// Keep-alive cache: component name → { container, instance }
|
|
51
|
+
// Map iteration order = insertion order, used for LRU eviction.
|
|
51
52
|
this._keepAliveCache = new Map();
|
|
53
|
+
// Optional cap on cached keep-alive instances. null = unbounded (default).
|
|
54
|
+
this._keepAliveMax = (typeof config.keepAliveMax === 'number' && config.keepAliveMax > 0)
|
|
55
|
+
? config.keepAliveMax
|
|
56
|
+
: null;
|
|
52
57
|
|
|
53
58
|
// Base path for sub-path deployments
|
|
54
59
|
// Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
|
|
@@ -173,7 +178,13 @@ class Router {
|
|
|
173
178
|
if (paramsAttr) {
|
|
174
179
|
try {
|
|
175
180
|
const params = JSON.parse(paramsAttr);
|
|
176
|
-
|
|
181
|
+
// Reject arrays, null, primitives - z-link-params must be a plain
|
|
182
|
+
// key/value object since we use it to interpolate :param placeholders.
|
|
183
|
+
if (typeof params !== 'object' || params === null || Array.isArray(params)) {
|
|
184
|
+
reportError(ErrorCode.ROUTER_RESOLVE, 'z-link-params must be a JSON object', { href, paramsAttr });
|
|
185
|
+
} else {
|
|
186
|
+
href = this._interpolateParams(href, params);
|
|
187
|
+
}
|
|
177
188
|
} catch (err) {
|
|
178
189
|
reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
|
|
179
190
|
}
|
|
@@ -395,7 +406,6 @@ class Router {
|
|
|
395
406
|
if (this._mode === 'hash') {
|
|
396
407
|
// Hash mode: stash the substate in a global - hashchange will check.
|
|
397
408
|
// We still push a history entry via a sentinel hash suffix.
|
|
398
|
-
const current = window.location.hash || '#/';
|
|
399
409
|
window.history.pushState(
|
|
400
410
|
{ [_ZQ_STATE_KEY]: 'substate', key, data },
|
|
401
411
|
'',
|
|
@@ -636,6 +646,9 @@ class Router {
|
|
|
636
646
|
// Keep-alive: reuse cached instance
|
|
637
647
|
if (isKeepAlive && componentName && this._keepAliveCache.has(componentName)) {
|
|
638
648
|
const cached = this._keepAliveCache.get(componentName);
|
|
649
|
+
// Refresh LRU order: move to most-recently-used position
|
|
650
|
+
this._keepAliveCache.delete(componentName);
|
|
651
|
+
this._keepAliveCache.set(componentName, cached);
|
|
639
652
|
// Hide all children, show the cached one
|
|
640
653
|
[...this._el.children].forEach(c => { c.style.display = 'none'; });
|
|
641
654
|
cached.container.style.display = '';
|
|
@@ -674,6 +687,7 @@ class Router {
|
|
|
674
687
|
|
|
675
688
|
if (isKeepAlive) {
|
|
676
689
|
this._keepAliveCache.set(componentName, { container, instance: this._instance });
|
|
690
|
+
this._evictKeepAliveLRU();
|
|
677
691
|
// Call activated() on first mount
|
|
678
692
|
if (this._instance._def.activated) {
|
|
679
693
|
try { this._instance._def.activated.call(this._instance); }
|
|
@@ -741,6 +755,27 @@ class Router {
|
|
|
741
755
|
|
|
742
756
|
// --- Destroy -------------------------------------------------------------
|
|
743
757
|
|
|
758
|
+
/**
|
|
759
|
+
* @private Evict least-recently-used keep-alive entries beyond _keepAliveMax.
|
|
760
|
+
* The current route's component is preserved even if it would otherwise be
|
|
761
|
+
* the oldest entry, to avoid destroying the active view.
|
|
762
|
+
*/
|
|
763
|
+
_evictKeepAliveLRU() {
|
|
764
|
+
if (this._keepAliveMax == null) return;
|
|
765
|
+
while (this._keepAliveCache.size > this._keepAliveMax) {
|
|
766
|
+
// Find oldest entry that isn't the currently-active component
|
|
767
|
+
let evictKey = null;
|
|
768
|
+
for (const key of this._keepAliveCache.keys()) {
|
|
769
|
+
if (key !== this._currentComponentName) { evictKey = key; break; }
|
|
770
|
+
}
|
|
771
|
+
if (evictKey == null) break;
|
|
772
|
+
const cached = this._keepAliveCache.get(evictKey);
|
|
773
|
+
this._keepAliveCache.delete(evictKey);
|
|
774
|
+
try { cached.instance.destroy(); } catch { /* swallow */ }
|
|
775
|
+
if (cached.container && cached.container.parentNode) cached.container.remove();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
744
779
|
destroy() {
|
|
745
780
|
// Remove window/document event listeners to prevent memory leaks
|
|
746
781
|
if (this._onNavEvent) {
|