zero-query 1.0.5 → 1.1.1
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 +3 -3
- package/cli/commands/build-api.js +442 -0
- package/cli/commands/build.js +33 -2
- package/cli/commands/bundle.js +174 -8
- package/cli/commands/dev/server.js +57 -3
- package/cli/scaffold/default/app/components/contacts/contacts.css +9 -9
- package/cli/scaffold/default/app/components/playground/playground.css +1 -1
- package/cli/scaffold/default/app/components/playground/playground.html +5 -5
- package/cli/scaffold/default/app/components/playground/playground.js +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +3 -3
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +4 -4
- package/cli/utils.js +16 -7
- package/dist/API.md +6603 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +387 -25
- package/dist/zquery.min.js +631 -2
- package/index.d.ts +9 -3
- package/index.js +10 -2
- package/package.json +3 -2
- package/src/component.js +243 -6
- package/src/reactive.js +4 -3
- package/src/router.js +79 -9
- package/src/store.js +49 -3
- package/tests/cli.test.js +343 -0
- package/tests/compare.test.js +486 -0
- package/tests/dev-server.test.js +489 -0
- package/tests/docs.test.js +1650 -0
- package/tests/electron-features.test.js +864 -0
- package/types/misc.d.ts +7 -7
- package/types/reactive.d.ts +1 -1
- package/types/store.d.ts +2 -1
package/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Lightweight modern frontend library - jQuery-like selectors, reactive
|
|
5
5
|
* components, SPA router, state management, HTTP client & utilities.
|
|
6
6
|
*
|
|
7
|
-
* @version 1.0
|
|
7
|
+
* @version 1.1.0
|
|
8
8
|
* @license MIT
|
|
9
9
|
* @see https://z-query.com/docs
|
|
10
10
|
*/
|
|
@@ -153,7 +153,7 @@ import type { createStore, getStore } from './types/store';
|
|
|
153
153
|
import type { HttpClient } from './types/http';
|
|
154
154
|
import type {
|
|
155
155
|
debounce, throttle, pipe, once, sleep,
|
|
156
|
-
escapeHtml, stripHtml, html, trust, uuid, camelCase, kebabCase,
|
|
156
|
+
escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
|
|
157
157
|
deepClone, deepMerge, isEqual, param, parseQuery,
|
|
158
158
|
StorageWrapper, EventBus,
|
|
159
159
|
range, unique, chunk, groupBy,
|
|
@@ -161,7 +161,7 @@ import type {
|
|
|
161
161
|
capitalize, truncate, clamp,
|
|
162
162
|
MemoizedFunction, memoize, RetryOptions, retry, timeout,
|
|
163
163
|
} from './types/utils';
|
|
164
|
-
import type { onError, ZQueryError, ErrorCode, guardCallback, validate } from './types/errors';
|
|
164
|
+
import type { onError, ZQueryError, ErrorCode, guardCallback, guardAsync, validate, formatError } from './types/errors';
|
|
165
165
|
import type { morph, morphElement, safeEval } from './types/misc';
|
|
166
166
|
|
|
167
167
|
/**
|
|
@@ -279,6 +279,7 @@ interface ZQueryStatic {
|
|
|
279
279
|
put: HttpClient['put'];
|
|
280
280
|
patch: HttpClient['patch'];
|
|
281
281
|
delete: HttpClient['delete'];
|
|
282
|
+
head: HttpClient['head'];
|
|
282
283
|
|
|
283
284
|
// -- Error Handling ------------------------------------------------------
|
|
284
285
|
/** Register a global error handler (or pass `null` to remove). */
|
|
@@ -289,8 +290,12 @@ interface ZQueryStatic {
|
|
|
289
290
|
ErrorCode: typeof ErrorCode;
|
|
290
291
|
/** Wrap a callback so thrown errors are caught and reported via the global handler. */
|
|
291
292
|
guardCallback: typeof guardCallback;
|
|
293
|
+
/** Wrap an async function so thrown errors are caught and reported via the global handler. */
|
|
294
|
+
guardAsync: typeof guardAsync;
|
|
292
295
|
/** Validate a required value is defined and of the expected type. */
|
|
293
296
|
validate: typeof validate;
|
|
297
|
+
/** Format a ZQueryError into a structured plain object. */
|
|
298
|
+
formatError: typeof formatError;
|
|
294
299
|
|
|
295
300
|
// -- Utilities -----------------------------------------------------------
|
|
296
301
|
debounce: typeof debounce;
|
|
@@ -303,6 +308,7 @@ interface ZQueryStatic {
|
|
|
303
308
|
stripHtml: typeof stripHtml;
|
|
304
309
|
html: typeof html;
|
|
305
310
|
trust: typeof trust;
|
|
311
|
+
TrustedHTML: typeof TrustedHTML;
|
|
306
312
|
uuid: typeof uuid;
|
|
307
313
|
camelCase: typeof camelCase;
|
|
308
314
|
kebabCase: typeof kebabCase;
|
package/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { query, queryAll, ZQueryCollection } from './src/core.js';
|
|
|
13
13
|
import { reactive, Signal, signal, computed, effect, batch, untracked } from './src/reactive.js';
|
|
14
14
|
import { component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style } from './src/component.js';
|
|
15
15
|
import { createRouter, getRouter, matchRoute } from './src/router.js';
|
|
16
|
-
import { createStore, getStore } from './src/store.js';
|
|
16
|
+
import { createStore, getStore, connectStore } from './src/store.js';
|
|
17
17
|
import { http } from './src/http.js';
|
|
18
18
|
import { morph, morphElement } from './src/diff.js';
|
|
19
19
|
import { safeEval } from './src/expression.js';
|
|
@@ -122,6 +122,7 @@ $.matchRoute = matchRoute;
|
|
|
122
122
|
// --- Store -----------------------------------------------------------------
|
|
123
123
|
$.store = createStore;
|
|
124
124
|
$.getStore = getStore;
|
|
125
|
+
$.connectStore = connectStore;
|
|
125
126
|
|
|
126
127
|
// --- HTTP ------------------------------------------------------------------
|
|
127
128
|
$.http = http;
|
|
@@ -186,6 +187,13 @@ $.libSize = '__LIB_SIZE__';
|
|
|
186
187
|
$.unitTests = '__UNIT_TESTS__';
|
|
187
188
|
$.meta = {}; // populated at build time by CLI bundler
|
|
188
189
|
|
|
190
|
+
// --- Environment detection -------------------------------------------------
|
|
191
|
+
$.isElectron = typeof navigator !== 'undefined' && /Electron/i.test(navigator.userAgent)
|
|
192
|
+
|| typeof process !== 'undefined' && process.versions != null && !!process.versions.electron;
|
|
193
|
+
$.platform = $.isElectron ? 'electron'
|
|
194
|
+
: typeof window !== 'undefined' ? 'browser'
|
|
195
|
+
: 'node';
|
|
196
|
+
|
|
189
197
|
$.noConflict = () => {
|
|
190
198
|
if (typeof window !== 'undefined' && window.$ === $) {
|
|
191
199
|
delete window.$;
|
|
@@ -216,7 +224,7 @@ export {
|
|
|
216
224
|
morph, morphElement,
|
|
217
225
|
safeEval,
|
|
218
226
|
createRouter, getRouter, matchRoute,
|
|
219
|
-
createStore, getStore,
|
|
227
|
+
createStore, getStore, connectStore,
|
|
220
228
|
http,
|
|
221
229
|
ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError,
|
|
222
230
|
debounce, throttle, pipe, once, sleep,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-query",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dev-lib": "node cli/index.js build --watch",
|
|
31
31
|
"bundle": "node cli/index.js bundle",
|
|
32
32
|
"bundle:app": "node cli/index.js bundle zquery-website",
|
|
33
|
+
"build:api": "node cli/commands/build-api.js",
|
|
33
34
|
"test": "vitest run",
|
|
34
35
|
"test:watch": "vitest",
|
|
35
36
|
"test:ssr": "node tests/test-ssr.js"
|
|
@@ -64,6 +65,6 @@
|
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"jsdom": "^28.1.0",
|
|
66
67
|
"vitest": "^4.0.18",
|
|
67
|
-
"zero-http": "^0.
|
|
68
|
+
"zero-http": "^0.3.5"
|
|
68
69
|
}
|
|
69
70
|
}
|
package/src/component.js
CHANGED
|
@@ -210,8 +210,58 @@ class Component {
|
|
|
210
210
|
this._slotContent['default'] = defaultSlotNodes.join('');
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
// Props
|
|
214
|
-
|
|
213
|
+
// Props - reactive when definition.props is defined, frozen otherwise
|
|
214
|
+
if (definition.props && typeof definition.props === 'object' && !Array.isArray(definition.props)) {
|
|
215
|
+
// Reactive props with type coercion and defaults
|
|
216
|
+
this.props = this._resolveReactiveProps(definition.props, props);
|
|
217
|
+
// MutationObserver to re-read props when parent re-renders and changes attributes
|
|
218
|
+
this._propObserver = new MutationObserver((mutations) => {
|
|
219
|
+
if (this._destroyed) return;
|
|
220
|
+
let changed = false;
|
|
221
|
+
for (const mut of mutations) {
|
|
222
|
+
if (mut.type === 'attributes') {
|
|
223
|
+
const attrName = mut.attributeName;
|
|
224
|
+
// Skip internal attributes
|
|
225
|
+
if (attrName.startsWith('z-') || attrName.startsWith('@') || attrName.startsWith(':') || attrName.startsWith('data-zq')) continue;
|
|
226
|
+
// Check if this is a defined prop (attribute names are lowercase)
|
|
227
|
+
const propName = attrName.startsWith(':') ? attrName.slice(1) : attrName;
|
|
228
|
+
if (propName in definition.props) {
|
|
229
|
+
changed = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (changed) {
|
|
234
|
+
this.props = this._resolveReactiveProps(definition.props, {});
|
|
235
|
+
this._scheduleUpdate();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
this._propObserver.observe(el, { attributes: true });
|
|
239
|
+
} else {
|
|
240
|
+
// Legacy: frozen props from parent
|
|
241
|
+
this.props = Object.freeze({ ...props });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Store connectors - auto-subscribe to store keys
|
|
245
|
+
this._storeCleanups = [];
|
|
246
|
+
this.stores = {};
|
|
247
|
+
if (definition.stores && typeof definition.stores === 'object') {
|
|
248
|
+
for (const [alias, connector] of Object.entries(definition.stores)) {
|
|
249
|
+
if (!connector || !connector._zqConnector) continue;
|
|
250
|
+
const { store, keys } = connector;
|
|
251
|
+
// Initialize snapshot
|
|
252
|
+
const snap = {};
|
|
253
|
+
for (const key of keys) {
|
|
254
|
+
snap[key] = store.state[key];
|
|
255
|
+
}
|
|
256
|
+
this.stores[alias] = snap;
|
|
257
|
+
// Subscribe to changes
|
|
258
|
+
const unsub = store.subscribe(keys, (key, value) => {
|
|
259
|
+
this.stores[alias][key] = value;
|
|
260
|
+
if (!this._destroyed) this._scheduleUpdate();
|
|
261
|
+
});
|
|
262
|
+
this._storeCleanups.push(unsub);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
215
265
|
|
|
216
266
|
// Reactive state
|
|
217
267
|
const initialState = typeof definition.state === 'function'
|
|
@@ -290,6 +340,61 @@ class Component {
|
|
|
290
340
|
});
|
|
291
341
|
}
|
|
292
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Resolve reactive props from the definition's prop schema.
|
|
345
|
+
* Reads from element attributes, applies type coercion and defaults.
|
|
346
|
+
* Passed props (from mount) override attributes.
|
|
347
|
+
* @param {object} propDefs - { propName: { type, default } }
|
|
348
|
+
* @param {object} passedProps - props passed programmatically from mount()
|
|
349
|
+
* @returns {object} resolved props (frozen)
|
|
350
|
+
*/
|
|
351
|
+
_resolveReactiveProps(propDefs, passedProps) {
|
|
352
|
+
const resolved = {};
|
|
353
|
+
for (const [name, schema] of Object.entries(propDefs)) {
|
|
354
|
+
const def = typeof schema === 'object' && schema !== null ? schema : { type: schema };
|
|
355
|
+
const type = def.type;
|
|
356
|
+
const defaultVal = def.default;
|
|
357
|
+
|
|
358
|
+
// Priority: passed props > dynamic :prop attribute > static attribute > default
|
|
359
|
+
if (name in passedProps) {
|
|
360
|
+
resolved[name] = passedProps[name];
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Check for dynamic :prop attribute (already evaluated by parent mount)
|
|
365
|
+
let rawAttr = this._el.getAttribute(':' + name);
|
|
366
|
+
let hasAttr = rawAttr !== null;
|
|
367
|
+
if (!hasAttr) {
|
|
368
|
+
rawAttr = this._el.getAttribute(name);
|
|
369
|
+
hasAttr = rawAttr !== null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (hasAttr && rawAttr !== null) {
|
|
373
|
+
resolved[name] = this._coercePropValue(rawAttr, type);
|
|
374
|
+
} else if (defaultVal !== undefined) {
|
|
375
|
+
resolved[name] = typeof defaultVal === 'function' ? defaultVal() : defaultVal;
|
|
376
|
+
} else {
|
|
377
|
+
resolved[name] = undefined;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return Object.freeze(resolved);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Coerce a raw attribute string to the specified type.
|
|
385
|
+
* @param {string} raw - attribute value string
|
|
386
|
+
* @param {Function} type - String, Number, Boolean, Object, or Array
|
|
387
|
+
* @returns {*}
|
|
388
|
+
*/
|
|
389
|
+
_coercePropValue(raw, type) {
|
|
390
|
+
if (type === Number) return Number(raw);
|
|
391
|
+
if (type === Boolean) return raw !== 'false' && raw !== '0' && raw !== '';
|
|
392
|
+
if (type === Object || type === Array) {
|
|
393
|
+
try { return JSON.parse(raw); } catch { return raw; }
|
|
394
|
+
}
|
|
395
|
+
return raw; // String or unspecified
|
|
396
|
+
}
|
|
397
|
+
|
|
293
398
|
// Load external templateUrl / styleUrl if specified (once per definition)
|
|
294
399
|
//
|
|
295
400
|
// Relative paths are resolved automatically against the component file's
|
|
@@ -1052,8 +1157,20 @@ class Component {
|
|
|
1052
1157
|
item.el.removeAttribute('z-if');
|
|
1053
1158
|
item.el.removeAttribute('z-else-if');
|
|
1054
1159
|
item.el.removeAttribute('z-else');
|
|
1160
|
+
// Transition enter for z-if elements becoming visible
|
|
1161
|
+
const transName = item.el.getAttribute('z-transition');
|
|
1162
|
+
if (transName) {
|
|
1163
|
+
item.el.removeAttribute('z-transition');
|
|
1164
|
+
this._transitionEnter(item.el, transName);
|
|
1165
|
+
}
|
|
1055
1166
|
} else {
|
|
1056
|
-
|
|
1167
|
+
// Transition leave for z-if elements being removed
|
|
1168
|
+
const transName = item.el.getAttribute('z-transition');
|
|
1169
|
+
if (transName) {
|
|
1170
|
+
this._transitionLeave(item.el, transName, () => item.el.remove());
|
|
1171
|
+
} else {
|
|
1172
|
+
item.el.remove();
|
|
1173
|
+
}
|
|
1057
1174
|
}
|
|
1058
1175
|
}
|
|
1059
1176
|
}
|
|
@@ -1062,8 +1179,31 @@ class Component {
|
|
|
1062
1179
|
this._el.querySelectorAll('[z-show]').forEach(el => {
|
|
1063
1180
|
if (el.closest('[z-pre]')) return;
|
|
1064
1181
|
const show = !!this._evalExpr(el.getAttribute('z-show'));
|
|
1065
|
-
|
|
1066
|
-
el.
|
|
1182
|
+
const transName = el.getAttribute('z-transition');
|
|
1183
|
+
const wasHidden = el.style.display === 'none' || el.hasAttribute('data-zq-hidden');
|
|
1184
|
+
|
|
1185
|
+
if (transName) {
|
|
1186
|
+
el.removeAttribute('z-show');
|
|
1187
|
+
if (show && wasHidden) {
|
|
1188
|
+
// Entering: was hidden, now showing
|
|
1189
|
+
el.style.display = '';
|
|
1190
|
+
el.removeAttribute('data-zq-hidden');
|
|
1191
|
+
this._transitionEnter(el, transName);
|
|
1192
|
+
} else if (!show && !wasHidden) {
|
|
1193
|
+
// Leaving: was visible, now hiding
|
|
1194
|
+
el.setAttribute('data-zq-hidden', '');
|
|
1195
|
+
this._transitionLeave(el, transName, () => {
|
|
1196
|
+
el.style.display = 'none';
|
|
1197
|
+
});
|
|
1198
|
+
} else {
|
|
1199
|
+
el.style.display = show ? '' : 'none';
|
|
1200
|
+
if (!show) el.setAttribute('data-zq-hidden', '');
|
|
1201
|
+
else el.removeAttribute('data-zq-hidden');
|
|
1202
|
+
}
|
|
1203
|
+
} else {
|
|
1204
|
+
el.style.display = show ? '' : 'none';
|
|
1205
|
+
el.removeAttribute('z-show');
|
|
1206
|
+
}
|
|
1067
1207
|
});
|
|
1068
1208
|
|
|
1069
1209
|
// -- z-bind:attr / :attr (dynamic attribute binding) -----------
|
|
@@ -1138,6 +1278,93 @@ class Component {
|
|
|
1138
1278
|
});
|
|
1139
1279
|
}
|
|
1140
1280
|
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
// Transition helpers - CSS class-based enter/leave animations
|
|
1283
|
+
//
|
|
1284
|
+
// z-transition="fade" generates:
|
|
1285
|
+
// Enter: .fade-enter-from → .fade-enter-active + .fade-enter-to
|
|
1286
|
+
// Leave: .fade-leave-from → .fade-leave-active + .fade-leave-to
|
|
1287
|
+
//
|
|
1288
|
+
// Or component-level transition config:
|
|
1289
|
+
// transition: { enter: 'animate-in', leave: 'animate-out', duration: 200 }
|
|
1290
|
+
// ---------------------------------------------------------------------------
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Run an enter transition on an element.
|
|
1294
|
+
* @param {Element} el - target element
|
|
1295
|
+
* @param {string} name - transition name (e.g. 'fade')
|
|
1296
|
+
*/
|
|
1297
|
+
_transitionEnter(el, name) {
|
|
1298
|
+
// Check for component-level transition config
|
|
1299
|
+
const cfg = this._def.transition;
|
|
1300
|
+
if (cfg && cfg.enter) {
|
|
1301
|
+
el.classList.add(cfg.enter);
|
|
1302
|
+
const duration = cfg.duration || 0;
|
|
1303
|
+
const cleanup = () => el.classList.remove(cfg.enter);
|
|
1304
|
+
if (duration > 0) {
|
|
1305
|
+
setTimeout(cleanup, duration);
|
|
1306
|
+
} else {
|
|
1307
|
+
el.addEventListener('transitionend', cleanup, { once: true });
|
|
1308
|
+
el.addEventListener('animationend', cleanup, { once: true });
|
|
1309
|
+
}
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// CSS class-based transition pattern
|
|
1314
|
+
el.classList.add(`${name}-enter-from`, `${name}-enter-active`);
|
|
1315
|
+
// Force reflow so the browser registers the initial state
|
|
1316
|
+
void el.offsetHeight;
|
|
1317
|
+
requestAnimationFrame(() => {
|
|
1318
|
+
el.classList.remove(`${name}-enter-from`);
|
|
1319
|
+
el.classList.add(`${name}-enter-to`);
|
|
1320
|
+
const onEnd = () => {
|
|
1321
|
+
el.classList.remove(`${name}-enter-active`, `${name}-enter-to`);
|
|
1322
|
+
};
|
|
1323
|
+
el.addEventListener('transitionend', onEnd, { once: true });
|
|
1324
|
+
el.addEventListener('animationend', onEnd, { once: true });
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Run a leave transition on an element, then call done().
|
|
1330
|
+
* @param {Element} el - target element
|
|
1331
|
+
* @param {string} name - transition name (e.g. 'fade')
|
|
1332
|
+
* @param {Function} done - callback when transition completes
|
|
1333
|
+
*/
|
|
1334
|
+
_transitionLeave(el, name, done) {
|
|
1335
|
+
// Check for component-level transition config
|
|
1336
|
+
const cfg = this._def.transition;
|
|
1337
|
+
if (cfg && cfg.leave) {
|
|
1338
|
+
el.classList.add(cfg.leave);
|
|
1339
|
+
const duration = cfg.duration || 0;
|
|
1340
|
+
const cleanup = () => {
|
|
1341
|
+
el.classList.remove(cfg.leave);
|
|
1342
|
+
done();
|
|
1343
|
+
};
|
|
1344
|
+
if (duration > 0) {
|
|
1345
|
+
setTimeout(cleanup, duration);
|
|
1346
|
+
} else {
|
|
1347
|
+
el.addEventListener('transitionend', cleanup, { once: true });
|
|
1348
|
+
el.addEventListener('animationend', cleanup, { once: true });
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// CSS class-based transition pattern
|
|
1354
|
+
el.classList.add(`${name}-leave-from`, `${name}-leave-active`);
|
|
1355
|
+
void el.offsetHeight;
|
|
1356
|
+
requestAnimationFrame(() => {
|
|
1357
|
+
el.classList.remove(`${name}-leave-from`);
|
|
1358
|
+
el.classList.add(`${name}-leave-to`);
|
|
1359
|
+
const onEnd = () => {
|
|
1360
|
+
el.classList.remove(`${name}-leave-active`, `${name}-leave-to`);
|
|
1361
|
+
done();
|
|
1362
|
+
};
|
|
1363
|
+
el.addEventListener('transitionend', onEnd, { once: true });
|
|
1364
|
+
el.addEventListener('animationend', onEnd, { once: true });
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1141
1368
|
// Programmatic state update (batch-friendly)
|
|
1142
1369
|
// Passing an empty object forces a re-render (useful for external state changes).
|
|
1143
1370
|
setState(partial) {
|
|
@@ -1161,6 +1388,16 @@ class Component {
|
|
|
1161
1388
|
try { this._def.destroyed.call(this); }
|
|
1162
1389
|
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._def._name}" destroyed() threw`, { component: this._def._name }, err); }
|
|
1163
1390
|
}
|
|
1391
|
+
// Clean up prop observer
|
|
1392
|
+
if (this._propObserver) {
|
|
1393
|
+
this._propObserver.disconnect();
|
|
1394
|
+
this._propObserver = null;
|
|
1395
|
+
}
|
|
1396
|
+
// Clean up store connectors
|
|
1397
|
+
if (this._storeCleanups) {
|
|
1398
|
+
this._storeCleanups.forEach(fn => fn());
|
|
1399
|
+
this._storeCleanups = [];
|
|
1400
|
+
}
|
|
1164
1401
|
this._listeners.forEach(({ event, handler }) => this._el.removeEventListener(event, handler));
|
|
1165
1402
|
this._listeners = [];
|
|
1166
1403
|
if (this._outsideListeners) {
|
|
@@ -1195,7 +1432,7 @@ class Component {
|
|
|
1195
1432
|
const _reservedKeys = new Set([
|
|
1196
1433
|
'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed', 'props',
|
|
1197
1434
|
'templateUrl', 'styleUrl', 'templates', 'base',
|
|
1198
|
-
'computed', 'watch'
|
|
1435
|
+
'computed', 'watch', 'stores', 'transition', 'activated', 'deactivated'
|
|
1199
1436
|
]);
|
|
1200
1437
|
|
|
1201
1438
|
|
package/src/reactive.js
CHANGED
|
@@ -206,13 +206,13 @@ export function effect(fn) {
|
|
|
206
206
|
export function batch(fn) {
|
|
207
207
|
if (Signal._batching) {
|
|
208
208
|
// Already inside a batch, just run
|
|
209
|
-
fn();
|
|
210
|
-
return;
|
|
209
|
+
return fn();
|
|
211
210
|
}
|
|
212
211
|
Signal._batching = true;
|
|
213
212
|
Signal._batchQueue.clear();
|
|
213
|
+
let result;
|
|
214
214
|
try {
|
|
215
|
-
fn();
|
|
215
|
+
result = fn();
|
|
216
216
|
} finally {
|
|
217
217
|
Signal._batching = false;
|
|
218
218
|
// Collect all unique subscribers across all queued signals
|
|
@@ -231,6 +231,7 @@ export function batch(fn) {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
|
+
return result;
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
|
package/src/router.js
CHANGED
|
@@ -47,6 +47,9 @@ class Router {
|
|
|
47
47
|
const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
|
|
48
48
|
this._mode = isFile ? 'hash' : (config.mode || 'history');
|
|
49
49
|
|
|
50
|
+
// Keep-alive cache: component name → { container, instance }
|
|
51
|
+
this._keepAliveCache = new Map();
|
|
52
|
+
|
|
50
53
|
// Base path for sub-path deployments
|
|
51
54
|
// Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
|
|
52
55
|
let rawBase = config.base;
|
|
@@ -604,34 +607,96 @@ class Router {
|
|
|
604
607
|
await prefetch(matched.component);
|
|
605
608
|
}
|
|
606
609
|
|
|
607
|
-
|
|
608
|
-
|
|
610
|
+
const isKeepAlive = !!matched.keepAlive;
|
|
611
|
+
const componentName = typeof matched.component === 'string' ? matched.component : null;
|
|
612
|
+
|
|
613
|
+
// Deactivate previous keep-alive instance (hide instead of destroy)
|
|
614
|
+
if (this._instance && this._currentKeepAlive && this._currentComponentName) {
|
|
615
|
+
const cached = this._keepAliveCache.get(this._currentComponentName);
|
|
616
|
+
if (cached) {
|
|
617
|
+
cached.container.style.display = 'none';
|
|
618
|
+
// Call deactivated() lifecycle hook
|
|
619
|
+
if (cached.instance._def.deactivated) {
|
|
620
|
+
try { cached.instance._def.deactivated.call(cached.instance); }
|
|
621
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${this._currentComponentName}" deactivated() threw`, { component: this._currentComponentName }, err); }
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
this._instance = null;
|
|
625
|
+
} else if (this._instance) {
|
|
626
|
+
// Destroy previous non-keepAlive instance
|
|
609
627
|
this._instance.destroy();
|
|
610
628
|
this._instance = null;
|
|
611
629
|
}
|
|
612
630
|
|
|
613
|
-
// Create container
|
|
614
631
|
const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
|
|
615
|
-
this._el.innerHTML = '';
|
|
616
632
|
|
|
617
633
|
// Pass route params and query as props
|
|
618
634
|
const props = { ...params, $route: to, $query: query, $params: params };
|
|
619
635
|
|
|
636
|
+
// Keep-alive: reuse cached instance
|
|
637
|
+
if (isKeepAlive && componentName && this._keepAliveCache.has(componentName)) {
|
|
638
|
+
const cached = this._keepAliveCache.get(componentName);
|
|
639
|
+
// Hide all children, show the cached one
|
|
640
|
+
[...this._el.children].forEach(c => { c.style.display = 'none'; });
|
|
641
|
+
cached.container.style.display = '';
|
|
642
|
+
this._instance = cached.instance;
|
|
643
|
+
this._currentKeepAlive = true;
|
|
644
|
+
this._currentComponentName = componentName;
|
|
645
|
+
// Call activated() lifecycle hook
|
|
646
|
+
if (cached.instance._def.activated) {
|
|
647
|
+
try { cached.instance._def.activated.call(cached.instance); }
|
|
648
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${componentName}" activated() threw`, { component: componentName }, err); }
|
|
649
|
+
}
|
|
650
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', componentName);
|
|
651
|
+
}
|
|
620
652
|
// If component is a string (registered name), mount it
|
|
621
|
-
if (
|
|
622
|
-
|
|
653
|
+
else if (componentName) {
|
|
654
|
+
// Hide all keep-alive cached children (don't destroy)
|
|
655
|
+
[...this._el.children].forEach(c => {
|
|
656
|
+
if (c.dataset.zqKeepAlive) {
|
|
657
|
+
c.style.display = 'none';
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
// Remove non-keep-alive children
|
|
661
|
+
[...this._el.children].forEach(c => {
|
|
662
|
+
if (!c.dataset.zqKeepAlive) c.remove();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const container = document.createElement(componentName);
|
|
666
|
+
if (isKeepAlive) container.dataset.zqKeepAlive = componentName;
|
|
623
667
|
this._el.appendChild(container);
|
|
624
668
|
try {
|
|
625
|
-
this._instance = mount(container,
|
|
669
|
+
this._instance = mount(container, componentName, props);
|
|
626
670
|
} catch (err) {
|
|
627
671
|
reportError(ErrorCode.COMP_NOT_FOUND, `Failed to mount component for route "${matched.path}"`, { component: matched.component, path: matched.path }, err);
|
|
628
672
|
return;
|
|
629
673
|
}
|
|
630
|
-
|
|
674
|
+
|
|
675
|
+
if (isKeepAlive) {
|
|
676
|
+
this._keepAliveCache.set(componentName, { container, instance: this._instance });
|
|
677
|
+
// Call activated() on first mount
|
|
678
|
+
if (this._instance._def.activated) {
|
|
679
|
+
try { this._instance._def.activated.call(this._instance); }
|
|
680
|
+
catch (err) { reportError(ErrorCode.COMP_LIFECYCLE, `Component "${componentName}" activated() threw`, { component: componentName }, err); }
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
this._currentKeepAlive = isKeepAlive;
|
|
685
|
+
this._currentComponentName = componentName;
|
|
686
|
+
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', componentName);
|
|
631
687
|
}
|
|
632
688
|
// If component is a render function
|
|
633
689
|
else if (typeof matched.component === 'function') {
|
|
634
|
-
|
|
690
|
+
// Clear non-keepAlive content
|
|
691
|
+
[...this._el.children].forEach(c => {
|
|
692
|
+
if (c.dataset.zqKeepAlive) c.style.display = 'none';
|
|
693
|
+
else c.remove();
|
|
694
|
+
});
|
|
695
|
+
const wrapper = document.createElement('div');
|
|
696
|
+
wrapper.innerHTML = matched.component(to);
|
|
697
|
+
while (wrapper.firstChild) this._el.appendChild(wrapper.firstChild);
|
|
698
|
+
this._currentKeepAlive = false;
|
|
699
|
+
this._currentComponentName = null;
|
|
635
700
|
if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
|
|
636
701
|
}
|
|
637
702
|
}
|
|
@@ -690,6 +755,11 @@ class Router {
|
|
|
690
755
|
document.removeEventListener('click', this._onLinkClick);
|
|
691
756
|
this._onLinkClick = null;
|
|
692
757
|
}
|
|
758
|
+
// Destroy all keep-alive cached instances
|
|
759
|
+
for (const [, cached] of this._keepAliveCache) {
|
|
760
|
+
cached.instance.destroy();
|
|
761
|
+
}
|
|
762
|
+
this._keepAliveCache.clear();
|
|
693
763
|
if (this._instance) this._instance.destroy();
|
|
694
764
|
this._listeners.clear();
|
|
695
765
|
this._substateListeners = [];
|
package/src/store.js
CHANGED
|
@@ -86,8 +86,9 @@ class Store {
|
|
|
86
86
|
batch(fn) {
|
|
87
87
|
this._batching = true;
|
|
88
88
|
this._batchQueue = [];
|
|
89
|
+
let result;
|
|
89
90
|
try {
|
|
90
|
-
fn(this.state);
|
|
91
|
+
result = fn(this.state);
|
|
91
92
|
} finally {
|
|
92
93
|
this._batching = false;
|
|
93
94
|
// Deduplicate: keep only the last change per key
|
|
@@ -100,6 +101,7 @@ class Store {
|
|
|
100
101
|
this._notifySubscribers(key, value, old);
|
|
101
102
|
}
|
|
102
103
|
}
|
|
104
|
+
return result;
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/**
|
|
@@ -187,8 +189,14 @@ class Store {
|
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
/**
|
|
190
|
-
* Subscribe to changes on a specific state key
|
|
191
|
-
*
|
|
192
|
+
* Subscribe to changes on a specific state key, multiple keys, or all changes.
|
|
193
|
+
*
|
|
194
|
+
* Signatures:
|
|
195
|
+
* subscribe(callback) → wildcard, fires on every change
|
|
196
|
+
* subscribe('key', callback) → fires when 'key' changes
|
|
197
|
+
* subscribe(['a','b'], callback) → fires when any listed key changes
|
|
198
|
+
*
|
|
199
|
+
* @param {string|string[]|Function} keyOrFn - state key, array of keys, or function for all changes
|
|
192
200
|
* @param {Function} [fn] - callback (key, value, oldValue)
|
|
193
201
|
* @returns {Function} - unsubscribe
|
|
194
202
|
*/
|
|
@@ -199,6 +207,16 @@ class Store {
|
|
|
199
207
|
return () => this._wildcards.delete(keyOrFn);
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
// Multi-key subscription: subscribe(['files', 'isProcessing'], callback)
|
|
211
|
+
if (Array.isArray(keyOrFn)) {
|
|
212
|
+
const keys = keyOrFn;
|
|
213
|
+
const handler = (key, value, old) => {
|
|
214
|
+
if (keys.includes(key)) fn(key, value, old);
|
|
215
|
+
};
|
|
216
|
+
this._wildcards.add(handler);
|
|
217
|
+
return () => this._wildcards.delete(handler);
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
if (!this._subscribers.has(keyOrFn)) {
|
|
203
221
|
this._subscribers.set(keyOrFn, new Set());
|
|
204
222
|
}
|
|
@@ -270,3 +288,31 @@ export function createStore(name, config) {
|
|
|
270
288
|
export function getStore(name = 'default') {
|
|
271
289
|
return _stores.get(name) || null;
|
|
272
290
|
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Store-Component Connector
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Create a store connector descriptor for use in component definitions.
|
|
299
|
+
* When used in a component's `stores` config, auto-subscribes to the
|
|
300
|
+
* listed keys on mount and cleans up on destroy.
|
|
301
|
+
*
|
|
302
|
+
* Usage:
|
|
303
|
+
* $.component('my-comp', {
|
|
304
|
+
* stores: {
|
|
305
|
+
* app: connectStore(appStore, ['files', 'isProcessing']),
|
|
306
|
+
* },
|
|
307
|
+
* render() {
|
|
308
|
+
* return `<div>${this.stores.app.files.length} files</div>`;
|
|
309
|
+
* }
|
|
310
|
+
* });
|
|
311
|
+
*
|
|
312
|
+
* @param {Store} store - the store instance to connect
|
|
313
|
+
* @param {string[]} keys - state keys to sync
|
|
314
|
+
* @returns {{ _zqConnector: true, store: Store, keys: string[] }}
|
|
315
|
+
*/
|
|
316
|
+
export function connectStore(store, keys) {
|
|
317
|
+
return { _zqConnector: true, store, keys };
|
|
318
|
+
}
|