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/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.5
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.0.5",
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.2.3"
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 (read-only from parent)
214
- this.props = Object.freeze({ ...props });
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
- item.el.remove();
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
- el.style.display = show ? '' : 'none';
1066
- el.removeAttribute('z-show');
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
- // Destroy previous
608
- if (this._instance) {
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 (typeof matched.component === 'string') {
622
- const container = document.createElement(matched.component);
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, matched.component, props);
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
- if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
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
- this._el.innerHTML = matched.component(to);
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
- * @param {string|Function} keyOrFn - state key, or function for all changes
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
+ }