zero-query 1.0.9 → 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/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
+ }
package/tests/cli.test.js CHANGED
@@ -1021,3 +1021,83 @@ describe('CLI - createProject', () => {
1021
1021
  spy.mockRestore();
1022
1022
  });
1023
1023
  });
1024
+
1025
+
1026
+ // ===========================================================================
1027
+ // minifyTemplateLiterals - regex literal awareness
1028
+ // ===========================================================================
1029
+
1030
+ describe('CLI - minifyTemplateLiterals regex handling', () => {
1031
+ let minifyTemplateLiterals;
1032
+
1033
+ beforeEach(async () => {
1034
+ const mod = await import('../cli/commands/bundle.js');
1035
+ minifyTemplateLiterals = mod.minifyTemplateLiterals;
1036
+ });
1037
+
1038
+ it('preserves code after regex containing backtick characters', () => {
1039
+ // Simulates: h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
1040
+ // The backtick inside the regex must not be mistaken for a template literal.
1041
+ const input = "h = h.replace(/\x60([^\x60]+)\x60/g, '<code>$1</code>');\nreturn h;";
1042
+ const result = minifyTemplateLiterals(input);
1043
+ expect(result).toContain('return h');
1044
+ expect(result).toContain('<code>$1</code>');
1045
+ });
1046
+
1047
+ it('does not eat closing braces after backtick-in-regex', () => {
1048
+ const input = [
1049
+ "var fmt = {",
1050
+ " md(raw) {",
1051
+ " let h = raw;",
1052
+ " h = h.replace(/\x60([^\x60]+)\x60/g, '<code>$1</code>');",
1053
+ " h = h.replace(/^[\\-\\*] (.+)$/gm, '<li>$1</li>');",
1054
+ " return h;",
1055
+ " }",
1056
+ "};",
1057
+ "var canvas = document.getElementById('bg-canvas');",
1058
+ ].join('\n');
1059
+ const result = minifyTemplateLiterals(input);
1060
+ expect(result).toContain('return h;');
1061
+ expect(result).toContain('};');
1062
+ expect(result).toContain("getElementById('bg-canvas')");
1063
+ });
1064
+
1065
+ it('handles regex with single backtick', () => {
1066
+ const input = "x.replace(/\x60/g, \"'\");\nvar y = 1;";
1067
+ const result = minifyTemplateLiterals(input);
1068
+ expect(result).toContain('var y = 1');
1069
+ });
1070
+
1071
+ it('handles regex backtick in character class', () => {
1072
+ const input = "x.match(/[\x60'\"]/g);\nvar z = 2;";
1073
+ const result = minifyTemplateLiterals(input);
1074
+ expect(result).toContain('var z = 2');
1075
+ });
1076
+
1077
+ it('does not confuse division operator with regex', () => {
1078
+ // After a number or identifier, / is division, not regex start
1079
+ const input = "var x = a / b;\nvar t = \x60hello\x60;";
1080
+ const result = minifyTemplateLiterals(input);
1081
+ expect(result).toContain('a / b');
1082
+ expect(result).toContain('\x60hello\x60');
1083
+ });
1084
+
1085
+ it('still minifies real template literals correctly', () => {
1086
+ const input = "var html = \x60<div> <span> text </span> </div>\x60;";
1087
+ const result = minifyTemplateLiterals(input);
1088
+ // Template whitespace should be collapsed
1089
+ expect(result).not.toContain(' <span> ');
1090
+ });
1091
+
1092
+ it('handles regex after return keyword', () => {
1093
+ const input = "return /\x60test\x60/g;\nvar after = 1;";
1094
+ const result = minifyTemplateLiterals(input);
1095
+ expect(result).toContain('var after = 1');
1096
+ });
1097
+
1098
+ it('handles regex after assignment operator', () => {
1099
+ const input = "var re = /\x60([^\x60]+)\x60/gi;\nvar next = true;";
1100
+ const result = minifyTemplateLiterals(input);
1101
+ expect(result).toContain('var next = true');
1102
+ });
1103
+ });