zero-query 0.7.5 → 0.8.6

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.
Files changed (64) hide show
  1. package/README.md +37 -27
  2. package/cli/commands/build.js +110 -1
  3. package/cli/commands/bundle.js +107 -22
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +28 -3
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +377 -0
  20. package/cli/commands/dev/server.js +8 -0
  21. package/cli/commands/dev/watcher.js +26 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +1 -1
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/app/components/home.js +137 -0
  27. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  28. package/cli/scaffold/{scripts → app}/store.js +6 -6
  29. package/cli/scaffold/assets/.gitkeep +0 -0
  30. package/cli/scaffold/{styles/styles.css → global.css} +3 -2
  31. package/cli/scaffold/index.html +11 -11
  32. package/dist/zquery.dist.zip +0 -0
  33. package/dist/zquery.js +746 -134
  34. package/dist/zquery.min.js +2 -2
  35. package/index.d.ts +11 -9
  36. package/index.js +15 -10
  37. package/package.json +3 -2
  38. package/src/component.js +161 -48
  39. package/src/core.js +57 -11
  40. package/src/diff.js +256 -58
  41. package/src/expression.js +33 -3
  42. package/src/reactive.js +37 -5
  43. package/src/router.js +195 -6
  44. package/tests/component.test.js +582 -0
  45. package/tests/core.test.js +251 -0
  46. package/tests/diff.test.js +333 -2
  47. package/tests/expression.test.js +148 -0
  48. package/tests/http.test.js +108 -0
  49. package/tests/reactive.test.js +148 -0
  50. package/tests/router.test.js +317 -0
  51. package/tests/store.test.js +126 -0
  52. package/tests/utils.test.js +161 -2
  53. package/types/collection.d.ts +17 -2
  54. package/types/component.d.ts +7 -0
  55. package/types/misc.d.ts +13 -0
  56. package/types/router.d.ts +30 -1
  57. package/cli/commands/dev.old.js +0 -520
  58. package/cli/scaffold/scripts/components/home.js +0 -137
  59. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
  60. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
  61. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  62. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  63. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  64. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
package/src/router.js CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Supports hash mode (#/path) and history mode (/path).
5
5
  * Route params, query strings, navigation guards, and lazy loading.
6
+ * Sub-route history substates for in-page UI changes (modals, tabs, etc.).
6
7
  *
7
8
  * Usage:
8
9
  * $.router({
@@ -17,9 +18,12 @@
17
18
  * });
18
19
  */
19
20
 
20
- import { mount, destroy } from './component.js';
21
+ import { mount, destroy, prefetch } from './component.js';
21
22
  import { reportError, ErrorCode } from './errors.js';
22
23
 
24
+ // Unique marker on history.state to identify zQuery-managed entries
25
+ const _ZQ_STATE_KEY = '__zq';
26
+
23
27
  class Router {
24
28
  constructor(config = {}) {
25
29
  this._el = null;
@@ -53,6 +57,10 @@ class Router {
53
57
  this._instance = null; // current mounted component
54
58
  this._resolving = false; // re-entrancy guard
55
59
 
60
+ // Sub-route history substates
61
+ this._substateListeners = []; // [(key, data) => bool|void]
62
+ this._inSubstate = false; // true while substate entries are in the history stack
63
+
56
64
  // Set outlet element
57
65
  if (config.el) {
58
66
  this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
@@ -67,7 +75,21 @@ class Router {
67
75
  if (this._mode === 'hash') {
68
76
  window.addEventListener('hashchange', () => this._resolve());
69
77
  } else {
70
- window.addEventListener('popstate', () => this._resolve());
78
+ window.addEventListener('popstate', (e) => {
79
+ // Check for substate pop first — if a listener handles it, don't route
80
+ const st = e.state;
81
+ if (st && st[_ZQ_STATE_KEY] === 'substate') {
82
+ const handled = this._fireSubstate(st.key, st.data, 'pop');
83
+ if (handled) return;
84
+ // Unhandled substate — fall through to route resolve
85
+ // _inSubstate stays true so the next non-substate pop triggers reset
86
+ } else if (this._inSubstate) {
87
+ // Popped past all substates — notify listeners to reset to defaults
88
+ this._inSubstate = false;
89
+ this._fireSubstate(null, null, 'reset');
90
+ }
91
+ this._resolve();
92
+ });
71
93
  }
72
94
 
73
95
  // Intercept link clicks for SPA navigation
@@ -150,6 +172,19 @@ class Router {
150
172
  });
151
173
  }
152
174
 
175
+ /**
176
+ * Get the full current URL (path + hash) for same-URL detection.
177
+ * @returns {string}
178
+ */
179
+ _currentURL() {
180
+ if (this._mode === 'hash') {
181
+ return window.location.hash.slice(1) || '/';
182
+ }
183
+ const pathname = window.location.pathname || '/';
184
+ const hash = window.location.hash || '';
185
+ return pathname + hash;
186
+ }
187
+
153
188
  navigate(path, options = {}) {
154
189
  // Interpolate :param placeholders if options.params is provided
155
190
  if (options.params) path = this._interpolateParams(path, options.params);
@@ -161,9 +196,48 @@ class Router {
161
196
  // Hash mode uses the URL hash for routing, so a #fragment can't live
162
197
  // in the URL. Store it as a scroll target for the destination component.
163
198
  if (fragment) window.__zqScrollTarget = fragment;
164
- window.location.hash = '#' + normalized;
199
+ const targetHash = '#' + normalized;
200
+ // Skip if already at this exact hash (prevents duplicate entries)
201
+ if (window.location.hash === targetHash && !options.force) return this;
202
+ window.location.hash = targetHash;
165
203
  } else {
166
- window.history.pushState(options.state || {}, '', this._base + normalized + hash);
204
+ const targetURL = this._base + normalized + hash;
205
+ const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
206
+
207
+ if (targetURL === currentURL && !options.force) {
208
+ // Same full URL (path + hash) — don't push duplicate entry.
209
+ // If only the hash changed to a fragment target, scroll to it.
210
+ if (fragment) {
211
+ const el = document.getElementById(fragment);
212
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
213
+ }
214
+ return this;
215
+ }
216
+
217
+ // Same route path but different hash fragment — use replaceState
218
+ // so back goes to the previous *route*, not the previous scroll position.
219
+ const targetPathOnly = this._base + normalized;
220
+ const currentPathOnly = window.location.pathname || '/';
221
+ if (targetPathOnly === currentPathOnly && hash && !options.force) {
222
+ window.history.replaceState(
223
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
224
+ '',
225
+ targetURL
226
+ );
227
+ // Scroll to the fragment target
228
+ if (fragment) {
229
+ const el = document.getElementById(fragment);
230
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
231
+ }
232
+ // Don't re-resolve — same route, just a hash change
233
+ return this;
234
+ }
235
+
236
+ window.history.pushState(
237
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
238
+ '',
239
+ targetURL
240
+ );
167
241
  this._resolve();
168
242
  }
169
243
  return this;
@@ -179,7 +253,11 @@ class Router {
179
253
  if (fragment) window.__zqScrollTarget = fragment;
180
254
  window.location.replace('#' + normalized);
181
255
  } else {
182
- window.history.replaceState(options.state || {}, '', this._base + normalized + hash);
256
+ window.history.replaceState(
257
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
258
+ '',
259
+ this._base + normalized + hash
260
+ );
183
261
  this._resolve();
184
262
  }
185
263
  return this;
@@ -234,6 +312,80 @@ class Router {
234
312
  return () => this._listeners.delete(fn);
235
313
  }
236
314
 
315
+ // --- Sub-route history substates -----------------------------------------
316
+
317
+ /**
318
+ * Push a lightweight history entry for in-component UI state.
319
+ * The URL path does NOT change — only a history entry is added so the
320
+ * back button can undo the UI change (close modal, revert tab, etc.)
321
+ * before navigating away.
322
+ *
323
+ * @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
324
+ * @param {*} data — arbitrary state (serializable)
325
+ * @returns {Router}
326
+ *
327
+ * @example
328
+ * // Open a modal and push a substate
329
+ * router.pushSubstate('modal', { id: 'confirm-delete' });
330
+ * // User hits back → onSubstate fires → close the modal
331
+ */
332
+ pushSubstate(key, data) {
333
+ this._inSubstate = true;
334
+ if (this._mode === 'hash') {
335
+ // Hash mode: stash the substate in a global — hashchange will check.
336
+ // We still push a history entry via a sentinel hash suffix.
337
+ const current = window.location.hash || '#/';
338
+ window.history.pushState(
339
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
340
+ '',
341
+ window.location.href
342
+ );
343
+ } else {
344
+ window.history.pushState(
345
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
346
+ '',
347
+ window.location.href // keep same URL
348
+ );
349
+ }
350
+ return this;
351
+ }
352
+
353
+ /**
354
+ * Register a listener for substate pops (back button on a substate entry).
355
+ * The callback receives `(key, data)` and should return `true` if it
356
+ * handled the pop (prevents route resolution). If no listener returns
357
+ * `true`, normal route resolution proceeds.
358
+ *
359
+ * @param {(key: string, data: any, action: string) => boolean|void} fn
360
+ * @returns {() => void} unsubscribe function
361
+ *
362
+ * @example
363
+ * const unsub = router.onSubstate((key, data) => {
364
+ * if (key === 'modal') { closeModal(); return true; }
365
+ * });
366
+ */
367
+ onSubstate(fn) {
368
+ this._substateListeners.push(fn);
369
+ return () => {
370
+ this._substateListeners = this._substateListeners.filter(f => f !== fn);
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Fire substate listeners. Returns true if any listener handled it.
376
+ * @private
377
+ */
378
+ _fireSubstate(key, data, action) {
379
+ for (const fn of this._substateListeners) {
380
+ try {
381
+ if (fn(key, data, action) === true) return true;
382
+ } catch (err) {
383
+ reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
384
+ }
385
+ }
386
+ return false;
387
+ }
388
+
237
389
  // --- Current state -------------------------------------------------------
238
390
 
239
391
  get current() { return this._current; }
@@ -294,6 +446,16 @@ class Router {
294
446
  }
295
447
 
296
448
  async __resolve() {
449
+ // Check if we're landing on a substate entry (e.g. page refresh on a
450
+ // substate bookmark, or hash-mode popstate). Fire listeners and bail
451
+ // if handled — the URL hasn't changed so there's no route to resolve.
452
+ const histState = window.history.state;
453
+ if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
454
+ const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
455
+ if (handled) return;
456
+ // No listener handled it — fall through to normal routing
457
+ }
458
+
297
459
  const fullPath = this.path;
298
460
  const [pathPart, queryString] = fullPath.split('?');
299
461
  const path = pathPart || '/';
@@ -321,6 +483,18 @@ class Router {
321
483
  const to = { route: matched, params, query, path };
322
484
  const from = this._current;
323
485
 
486
+ // Same-route optimization: if the resolved route is the same component
487
+ // with the same params, skip the full destroy/mount cycle and just
488
+ // update props. This prevents flashing and unnecessary DOM churn.
489
+ if (from && this._instance && matched.component === from.route.component) {
490
+ const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
491
+ const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
492
+ if (sameParams && sameQuery) {
493
+ // Identical navigation — nothing to do
494
+ return;
495
+ }
496
+ }
497
+
324
498
  // Run before guards
325
499
  for (const guard of this._guards.before) {
326
500
  try {
@@ -339,7 +513,11 @@ class Router {
339
513
  if (rFrag) window.__zqScrollTarget = rFrag;
340
514
  window.location.replace('#' + rNorm);
341
515
  } else {
342
- window.history.replaceState({}, '', this._base + rNorm + rHash);
516
+ window.history.replaceState(
517
+ { [_ZQ_STATE_KEY]: 'route' },
518
+ '',
519
+ this._base + rNorm + rHash
520
+ );
343
521
  }
344
522
  return this.__resolve();
345
523
  }
@@ -362,6 +540,12 @@ class Router {
362
540
 
363
541
  // Mount component into outlet
364
542
  if (this._el && matched.component) {
543
+ // Pre-load external templates/styles so the mount renders synchronously
544
+ // (keeps old content visible during the fetch instead of showing blank)
545
+ if (typeof matched.component === 'string') {
546
+ await prefetch(matched.component);
547
+ }
548
+
365
549
  // Destroy previous
366
550
  if (this._instance) {
367
551
  this._instance.destroy();
@@ -369,6 +553,7 @@ class Router {
369
553
  }
370
554
 
371
555
  // Create container
556
+ const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
372
557
  this._el.innerHTML = '';
373
558
 
374
559
  // Pass route params and query as props
@@ -379,10 +564,12 @@ class Router {
379
564
  const container = document.createElement(matched.component);
380
565
  this._el.appendChild(container);
381
566
  this._instance = mount(container, matched.component, props);
567
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
382
568
  }
383
569
  // If component is a render function
384
570
  else if (typeof matched.component === 'function') {
385
571
  this._el.innerHTML = matched.component(to);
572
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
386
573
  }
387
574
  }
388
575
 
@@ -400,6 +587,8 @@ class Router {
400
587
  destroy() {
401
588
  if (this._instance) this._instance.destroy();
402
589
  this._listeners.clear();
590
+ this._substateListeners = [];
591
+ this._inSubstate = false;
403
592
  this._routes = [];
404
593
  this._guards = { before: [], after: [] };
405
594
  }