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.
- package/README.md +37 -27
- package/cli/commands/build.js +110 -1
- package/cli/commands/bundle.js +107 -22
- package/cli/commands/create.js +1 -1
- package/cli/commands/dev/devtools/index.js +56 -0
- package/cli/commands/dev/devtools/js/components.js +49 -0
- package/cli/commands/dev/devtools/js/core.js +409 -0
- package/cli/commands/dev/devtools/js/elements.js +413 -0
- package/cli/commands/dev/devtools/js/network.js +166 -0
- package/cli/commands/dev/devtools/js/performance.js +73 -0
- package/cli/commands/dev/devtools/js/router.js +105 -0
- package/cli/commands/dev/devtools/js/source.js +132 -0
- package/cli/commands/dev/devtools/js/stats.js +35 -0
- package/cli/commands/dev/devtools/js/tabs.js +79 -0
- package/cli/commands/dev/devtools/panel.html +95 -0
- package/cli/commands/dev/devtools/styles.css +244 -0
- package/cli/commands/dev/index.js +28 -3
- package/cli/commands/dev/logger.js +6 -1
- package/cli/commands/dev/overlay.js +377 -0
- package/cli/commands/dev/server.js +8 -0
- package/cli/commands/dev/watcher.js +26 -1
- package/cli/help.js +8 -5
- package/cli/scaffold/{scripts → app}/app.js +1 -1
- package/cli/scaffold/{scripts → app}/components/about.js +4 -4
- package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
- package/cli/scaffold/app/components/home.js +137 -0
- package/cli/scaffold/{scripts → app}/routes.js +1 -1
- package/cli/scaffold/{scripts → app}/store.js +6 -6
- package/cli/scaffold/assets/.gitkeep +0 -0
- package/cli/scaffold/{styles/styles.css → global.css} +3 -2
- package/cli/scaffold/index.html +11 -11
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +746 -134
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +11 -9
- package/index.js +15 -10
- package/package.json +3 -2
- package/src/component.js +161 -48
- package/src/core.js +57 -11
- package/src/diff.js +256 -58
- package/src/expression.js +33 -3
- package/src/reactive.js +37 -5
- package/src/router.js +195 -6
- package/tests/component.test.js +582 -0
- package/tests/core.test.js +251 -0
- package/tests/diff.test.js +333 -2
- package/tests/expression.test.js +148 -0
- package/tests/http.test.js +108 -0
- package/tests/reactive.test.js +148 -0
- package/tests/router.test.js +317 -0
- package/tests/store.test.js +126 -0
- package/tests/utils.test.js +161 -2
- package/types/collection.d.ts +17 -2
- package/types/component.d.ts +7 -0
- package/types/misc.d.ts +13 -0
- package/types/router.d.ts +30 -1
- package/cli/commands/dev.old.js +0 -520
- package/cli/scaffold/scripts/components/home.js +0 -137
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +0 -0
- /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
- /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
- /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', () =>
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|