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
@@ -330,3 +330,320 @@ describe('Router — destroy', () => {
330
330
  expect(router._listeners.size).toBe(0);
331
331
  });
332
332
  });
333
+
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Wildcard / catch-all routes
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('Router — wildcard routes', () => {
340
+ it('compiles wildcard route', () => {
341
+ const router = createRouter({
342
+ mode: 'hash',
343
+ routes: [
344
+ { path: '/docs/:section', component: 'docs-page' },
345
+ { path: '*', component: 'home-page' },
346
+ ],
347
+ });
348
+ // Wildcard is compiled as last route
349
+ expect(router._routes.length).toBeGreaterThanOrEqual(2);
350
+ });
351
+ });
352
+
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Query string handling
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe('Router — query parsing', () => {
359
+ it('_normalizePath strips query string for route matching', () => {
360
+ const router = createRouter({
361
+ mode: 'hash',
362
+ routes: [],
363
+ });
364
+ // _normalizePath does not strip query params — it only normalizes slashes and base prefix
365
+ const path = router._normalizePath('/docs?section=intro');
366
+ expect(path).toBe('/docs?section=intro');
367
+ });
368
+ });
369
+
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Multiple guards
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe('Router — multiple guards', () => {
376
+ it('registers multiple beforeEach guards', () => {
377
+ const router = createRouter({
378
+ mode: 'hash',
379
+ routes: [{ path: '/', component: 'home-page' }],
380
+ });
381
+ const g1 = vi.fn();
382
+ const g2 = vi.fn();
383
+ router.beforeEach(g1);
384
+ router.beforeEach(g2);
385
+ expect(router._guards.before.length).toBe(2);
386
+ expect(router._guards.before).toContain(g1);
387
+ expect(router._guards.before).toContain(g2);
388
+ });
389
+
390
+ it('registers multiple afterEach guards', () => {
391
+ const router = createRouter({
392
+ mode: 'hash',
393
+ routes: [{ path: '/', component: 'home-page' }],
394
+ });
395
+ const g1 = vi.fn();
396
+ const g2 = vi.fn();
397
+ router.afterEach(g1);
398
+ router.afterEach(g2);
399
+ expect(router._guards.after.length).toBe(2);
400
+ });
401
+ });
402
+
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Route with multiple params
406
+ // ---------------------------------------------------------------------------
407
+
408
+ describe('Router — multi-param routes', () => {
409
+ it('compiles route with multiple params', () => {
410
+ const router = createRouter({
411
+ mode: 'hash',
412
+ routes: [
413
+ { path: '/post/:pid/comment/:cid', component: 'user-page' },
414
+ ],
415
+ });
416
+ const route = router._routes[0];
417
+ expect(route._regex.test('/post/1/comment/5')).toBe(true);
418
+ expect(route._keys).toEqual(['pid', 'cid']);
419
+ });
420
+ });
421
+
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Same-path deduplication
425
+ // ---------------------------------------------------------------------------
426
+
427
+ describe('Router — same-path deduplication', () => {
428
+ let router;
429
+
430
+ beforeEach(() => {
431
+ document.body.innerHTML = '<div id="app"></div>';
432
+ window.location.hash = '#/';
433
+ router = createRouter({
434
+ el: '#app',
435
+ mode: 'hash',
436
+ routes: [
437
+ { path: '/', component: 'home-page' },
438
+ { path: '/about', component: 'about-page' },
439
+ ],
440
+ });
441
+ });
442
+
443
+ it('skips duplicate hash navigation to the same path', () => {
444
+ router.navigate('/about');
445
+ expect(window.location.hash).toBe('#/about');
446
+ // Navigate to the same path again — should be a no-op
447
+ const result = router.navigate('/about');
448
+ expect(window.location.hash).toBe('#/about');
449
+ expect(result).toBe(router); // still returns the router chain
450
+ });
451
+
452
+ it('allows forced duplicate navigation with options.force', () => {
453
+ router.navigate('/about');
454
+ // Force navigation to same path
455
+ router.navigate('/about', { force: true });
456
+ expect(window.location.hash).toBe('#/about');
457
+ });
458
+ });
459
+
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // History mode — same-path / hash-only navigation
463
+ // ---------------------------------------------------------------------------
464
+
465
+ describe('Router — history mode deduplication', () => {
466
+ let router;
467
+ let pushSpy, replaceSpy;
468
+
469
+ beforeEach(() => {
470
+ document.body.innerHTML = '<div id="app"></div>';
471
+ pushSpy = vi.spyOn(window.history, 'pushState');
472
+ replaceSpy = vi.spyOn(window.history, 'replaceState');
473
+ router = createRouter({
474
+ el: '#app',
475
+ mode: 'history',
476
+ routes: [
477
+ { path: '/', component: 'home-page' },
478
+ { path: '/about', component: 'about-page' },
479
+ { path: '/docs', component: 'docs-page' },
480
+ ],
481
+ });
482
+ // Reset spy call counts after initial resolve
483
+ pushSpy.mockClear();
484
+ replaceSpy.mockClear();
485
+ });
486
+
487
+ afterEach(() => {
488
+ pushSpy.mockRestore();
489
+ replaceSpy.mockRestore();
490
+ });
491
+
492
+ it('uses pushState for different routes', () => {
493
+ router.navigate('/about');
494
+ expect(pushSpy).toHaveBeenCalledTimes(1);
495
+ });
496
+
497
+ it('uses replaceState for same-route hash-only change', () => {
498
+ // Navigate to /docs first
499
+ router.navigate('/docs');
500
+ pushSpy.mockClear();
501
+ replaceSpy.mockClear();
502
+ // Navigate to /docs#section — same route, different hash
503
+ router.navigate('/docs#section');
504
+ expect(pushSpy).not.toHaveBeenCalled();
505
+ expect(replaceSpy).toHaveBeenCalledTimes(1);
506
+ });
507
+ });
508
+
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Sub-route history substates
512
+ // ---------------------------------------------------------------------------
513
+
514
+ describe('Router — substates', () => {
515
+ let router;
516
+ let pushSpy;
517
+
518
+ beforeEach(() => {
519
+ document.body.innerHTML = '<div id="app"></div>';
520
+ pushSpy = vi.spyOn(window.history, 'pushState');
521
+ router = createRouter({
522
+ el: '#app',
523
+ mode: 'history',
524
+ routes: [
525
+ { path: '/', component: 'home-page' },
526
+ ],
527
+ });
528
+ pushSpy.mockClear();
529
+ });
530
+
531
+ afterEach(() => {
532
+ pushSpy.mockRestore();
533
+ });
534
+
535
+ it('pushSubstate pushes a history entry with substate marker', () => {
536
+ router.pushSubstate('modal', { id: 'confirm' });
537
+ expect(pushSpy).toHaveBeenCalledTimes(1);
538
+ const state = pushSpy.mock.calls[0][0];
539
+ expect(state.__zq).toBe('substate');
540
+ expect(state.key).toBe('modal');
541
+ expect(state.data).toEqual({ id: 'confirm' });
542
+ });
543
+
544
+ it('onSubstate registers and unregisters listeners', () => {
545
+ const fn = vi.fn();
546
+ const unsub = router.onSubstate(fn);
547
+ expect(router._substateListeners).toContain(fn);
548
+ unsub();
549
+ expect(router._substateListeners).not.toContain(fn);
550
+ });
551
+
552
+ it('_fireSubstate calls listeners and returns true if any handles it', () => {
553
+ const fn1 = vi.fn(() => false);
554
+ const fn2 = vi.fn(() => true);
555
+ router.onSubstate(fn1);
556
+ router.onSubstate(fn2);
557
+ const handled = router._fireSubstate('modal', { id: 'x' }, 'pop');
558
+ expect(fn1).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
559
+ expect(fn2).toHaveBeenCalledWith('modal', { id: 'x' }, 'pop');
560
+ expect(handled).toBe(true);
561
+ });
562
+
563
+ it('_fireSubstate returns false if no listener handles it', () => {
564
+ const fn = vi.fn(() => undefined);
565
+ router.onSubstate(fn);
566
+ const handled = router._fireSubstate('tab', { index: 0 }, 'pop');
567
+ expect(handled).toBe(false);
568
+ });
569
+
570
+ it('_fireSubstate catches errors in listeners', () => {
571
+ const fn = vi.fn(() => { throw new Error('oops'); });
572
+ router.onSubstate(fn);
573
+ // Should not throw
574
+ expect(() => router._fireSubstate('modal', {}, 'pop')).not.toThrow();
575
+ });
576
+
577
+ it('destroy clears substate listeners', () => {
578
+ router.onSubstate(() => {});
579
+ router.onSubstate(() => {});
580
+ router.destroy();
581
+ expect(router._substateListeners.length).toBe(0);
582
+ });
583
+
584
+ it('pushSubstate sets _inSubstate flag', () => {
585
+ expect(router._inSubstate).toBe(false);
586
+ router.pushSubstate('tab', { id: 'a' });
587
+ expect(router._inSubstate).toBe(true);
588
+ });
589
+
590
+ it('popstate past all substates fires reset action', () => {
591
+ const fn = vi.fn(() => true);
592
+ router.onSubstate(fn);
593
+ router.pushSubstate('tab', { id: 'a' });
594
+ expect(router._inSubstate).toBe(true);
595
+
596
+ // Simulate popstate landing on a non-substate entry
597
+ const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
598
+ window.dispatchEvent(evt);
599
+
600
+ // Should have fired with reset action
601
+ expect(fn).toHaveBeenCalledWith(null, null, 'reset');
602
+ expect(router._inSubstate).toBe(false);
603
+ });
604
+
605
+ it('popstate on a substate entry keeps _inSubstate true', () => {
606
+ const fn = vi.fn(() => true);
607
+ router.onSubstate(fn);
608
+ router.pushSubstate('tab', { id: 'a' });
609
+ router.pushSubstate('tab', { id: 'b' });
610
+
611
+ // Simulate popstate landing on a substate entry
612
+ const evt = new PopStateEvent('popstate', {
613
+ state: { __zq: 'substate', key: 'tab', data: { id: 'a' } }
614
+ });
615
+ window.dispatchEvent(evt);
616
+
617
+ expect(fn).toHaveBeenCalledWith('tab', { id: 'a' }, 'pop');
618
+ expect(router._inSubstate).toBe(true);
619
+ });
620
+
621
+ it('reset action is not fired when no substates were active', () => {
622
+ const fn = vi.fn();
623
+ router.onSubstate(fn);
624
+ // _inSubstate is false, so popstate on a route entry should not fire reset
625
+ const evt = new PopStateEvent('popstate', { state: { __zq: 'route' } });
626
+ window.dispatchEvent(evt);
627
+ // fn should NOT have been called with 'reset'
628
+ const resetCalls = fn.mock.calls.filter(c => c[2] === 'reset');
629
+ expect(resetCalls.length).toBe(0);
630
+ });
631
+ });
632
+
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Edge cases
636
+ // ---------------------------------------------------------------------------
637
+
638
+ describe('Router — edge cases', () => {
639
+ it('handles empty routes array', () => {
640
+ const router = createRouter({ mode: 'hash', routes: [] });
641
+ expect(router._routes.length).toBe(0);
642
+ });
643
+
644
+ it('getRouter returns null before creation', () => {
645
+ // After the tests create routers, getRouter should return the latest
646
+ const r = getRouter();
647
+ expect(r).toBeDefined();
648
+ });
649
+ });
@@ -251,3 +251,129 @@ describe('Store — snapshot & replaceState', () => {
251
251
  expect(store.history.length).toBe(0);
252
252
  });
253
253
  });
254
+
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Multiple middleware
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('Store — multiple middleware', () => {
261
+ it('runs middleware in order', () => {
262
+ const order = [];
263
+ const store = createStore('mw-multi', {
264
+ state: { x: 0 },
265
+ actions: { inc(state) { state.x++; } },
266
+ });
267
+ store.use(() => { order.push('a'); });
268
+ store.use(() => { order.push('b'); });
269
+ store.dispatch('inc');
270
+ expect(order).toEqual(['a', 'b']);
271
+ });
272
+
273
+ it('second middleware can block even if first passes', () => {
274
+ const store = createStore('mw-multi-block', {
275
+ state: { x: 0 },
276
+ actions: { inc(state) { state.x++; } },
277
+ });
278
+ store.use(() => true);
279
+ store.use(() => false);
280
+ store.dispatch('inc');
281
+ expect(store.state.x).toBe(0);
282
+ });
283
+ });
284
+
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Async actions
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe('Store — async actions', () => {
291
+ it('supports async action returning promise', async () => {
292
+ const store = createStore('async-1', {
293
+ state: { data: null },
294
+ actions: {
295
+ async fetchData(state) {
296
+ state.data = await Promise.resolve('loaded');
297
+ },
298
+ },
299
+ });
300
+ await store.dispatch('fetchData');
301
+ expect(store.state.data).toBe('loaded');
302
+ });
303
+ });
304
+
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Subscriber deduplication
308
+ // ---------------------------------------------------------------------------
309
+
310
+ describe('Store — subscriber edge cases', () => {
311
+ it('same function subscribed twice fires twice', () => {
312
+ const store = createStore('sub-dedup', {
313
+ state: { x: 0 },
314
+ actions: { inc(state) { state.x++; } },
315
+ });
316
+ const fn = vi.fn();
317
+ store.subscribe('x', fn);
318
+ store.subscribe('x', fn); // Set deduplicates
319
+ store.dispatch('inc');
320
+ expect(fn).toHaveBeenCalledOnce(); // Set prevents duplicates
321
+ });
322
+
323
+ it('wildcard and key subscriber both fire', () => {
324
+ const store = createStore('sub-both', {
325
+ state: { x: 0 },
326
+ actions: { inc(state) { state.x++; } },
327
+ });
328
+ const keyFn = vi.fn();
329
+ const wildFn = vi.fn();
330
+ store.subscribe('x', keyFn);
331
+ store.subscribe(wildFn);
332
+ store.dispatch('inc');
333
+ expect(keyFn).toHaveBeenCalledOnce();
334
+ expect(wildFn).toHaveBeenCalledOnce();
335
+ });
336
+ });
337
+
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Action return value
341
+ // ---------------------------------------------------------------------------
342
+
343
+ describe('Store — action return value', () => {
344
+ it('dispatch returns action result', () => {
345
+ const store = createStore('ret-1', {
346
+ state: { x: 0 },
347
+ actions: { compute(state) { return state.x + 10; } },
348
+ });
349
+ expect(store.dispatch('compute')).toBe(10);
350
+ });
351
+ });
352
+
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Getters with multiple state keys
356
+ // ---------------------------------------------------------------------------
357
+
358
+ describe('Store — complex getters', () => {
359
+ it('getter uses multiple state keys', () => {
360
+ const store = createStore('getter-multi', {
361
+ state: { firstName: 'Tony', lastName: 'W' },
362
+ getters: {
363
+ fullName: (state) => `${state.firstName} ${state.lastName}`,
364
+ },
365
+ });
366
+ expect(store.getters.fullName).toBe('Tony W');
367
+ });
368
+
369
+ it('getter recalculates after state change', () => {
370
+ const store = createStore('getter-recalc', {
371
+ state: { count: 2 },
372
+ actions: { set(state, v) { state.count = v; } },
373
+ getters: { doubled: (s) => s.count * 2 },
374
+ });
375
+ expect(store.getters.doubled).toBe(4);
376
+ store.dispatch('set', 10);
377
+ expect(store.getters.doubled).toBe(20);
378
+ });
379
+ });
@@ -1,9 +1,9 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import {
3
3
  debounce, throttle, pipe, once, sleep,
4
- escapeHtml, trust, uuid, camelCase, kebabCase,
4
+ escapeHtml, html, trust, uuid, camelCase, kebabCase,
5
5
  deepClone, deepMerge, isEqual, param, parseQuery,
6
- bus,
6
+ storage, session, bus,
7
7
  } from '../src/utils.js';
8
8
 
9
9
 
@@ -298,6 +298,165 @@ describe('parseQuery', () => {
298
298
  });
299
299
 
300
300
 
301
+ // ---------------------------------------------------------------------------
302
+ // html template tag
303
+ // ---------------------------------------------------------------------------
304
+
305
+ describe('html template tag', () => {
306
+ it('auto-escapes interpolated values', () => {
307
+ const userInput = '<script>alert("xss")</script>';
308
+ const result = html`<div>${userInput}</div>`;
309
+ expect(result).toContain('&lt;script&gt;');
310
+ expect(result).not.toContain('<script>');
311
+ });
312
+
313
+ it('does not escape trusted HTML', () => {
314
+ const safe = trust('<b>bold</b>');
315
+ const result = html`<div>${safe}</div>`;
316
+ expect(result).toContain('<b>bold</b>');
317
+ });
318
+
319
+ it('handles null/undefined values', () => {
320
+ const result = html`<span>${null}</span>`;
321
+ expect(result).toBe('<span></span>');
322
+ });
323
+ });
324
+
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // storage helpers
328
+ // ---------------------------------------------------------------------------
329
+
330
+ describe('storage (localStorage wrapper)', () => {
331
+ beforeEach(() => { localStorage.clear(); });
332
+
333
+ it('set and get a value', () => {
334
+ storage.set('key', { a: 1 });
335
+ expect(storage.get('key')).toEqual({ a: 1 });
336
+ });
337
+
338
+ it('returns fallback for missing key', () => {
339
+ expect(storage.get('missing', 'default')).toBe('default');
340
+ });
341
+
342
+ it('returns null as default fallback', () => {
343
+ expect(storage.get('missing')).toBeNull();
344
+ });
345
+
346
+ it('remove deletes a key', () => {
347
+ storage.set('key', 42);
348
+ storage.remove('key');
349
+ expect(storage.get('key')).toBeNull();
350
+ });
351
+
352
+ it('clear removes all keys', () => {
353
+ storage.set('a', 1);
354
+ storage.set('b', 2);
355
+ storage.clear();
356
+ expect(storage.get('a')).toBeNull();
357
+ expect(storage.get('b')).toBeNull();
358
+ });
359
+
360
+ it('handles non-JSON values gracefully', () => {
361
+ localStorage.setItem('bad', '{not json}');
362
+ expect(storage.get('bad', 'fallback')).toBe('fallback');
363
+ });
364
+ });
365
+
366
+
367
+ describe('session (sessionStorage wrapper)', () => {
368
+ beforeEach(() => { sessionStorage.clear(); });
369
+
370
+ it('set and get a value', () => {
371
+ session.set('key', [1, 2, 3]);
372
+ expect(session.get('key')).toEqual([1, 2, 3]);
373
+ });
374
+
375
+ it('returns fallback for missing key', () => {
376
+ expect(session.get('missing', 'default')).toBe('default');
377
+ });
378
+
379
+ it('remove deletes a key', () => {
380
+ session.set('key', 'val');
381
+ session.remove('key');
382
+ expect(session.get('key')).toBeNull();
383
+ });
384
+
385
+ it('clear removes all keys', () => {
386
+ session.set('a', 1);
387
+ session.clear();
388
+ expect(session.get('a')).toBeNull();
389
+ });
390
+ });
391
+
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Event bus
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe('bus (event bus)', () => {
398
+ beforeEach(() => { bus.clear(); });
399
+
400
+ it('on() and emit()', () => {
401
+ const fn = vi.fn();
402
+ bus.on('test', fn);
403
+ bus.emit('test', 'data');
404
+ expect(fn).toHaveBeenCalledWith('data');
405
+ });
406
+
407
+ it('off() removes handler', () => {
408
+ const fn = vi.fn();
409
+ bus.on('test', fn);
410
+ bus.off('test', fn);
411
+ bus.emit('test');
412
+ expect(fn).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it('on() returns unsubscribe function', () => {
416
+ const fn = vi.fn();
417
+ const unsub = bus.on('test', fn);
418
+ unsub();
419
+ bus.emit('test');
420
+ expect(fn).not.toHaveBeenCalled();
421
+ });
422
+
423
+ it('once() fires handler only once', () => {
424
+ const fn = vi.fn();
425
+ bus.once('test', fn);
426
+ bus.emit('test', 'first');
427
+ bus.emit('test', 'second');
428
+ expect(fn).toHaveBeenCalledOnce();
429
+ expect(fn).toHaveBeenCalledWith('first');
430
+ });
431
+
432
+ it('emit with multiple args', () => {
433
+ const fn = vi.fn();
434
+ bus.on('test', fn);
435
+ bus.emit('test', 1, 2, 3);
436
+ expect(fn).toHaveBeenCalledWith(1, 2, 3);
437
+ });
438
+
439
+ it('multiple handlers on same event', () => {
440
+ const fn1 = vi.fn();
441
+ const fn2 = vi.fn();
442
+ bus.on('test', fn1);
443
+ bus.on('test', fn2);
444
+ bus.emit('test');
445
+ expect(fn1).toHaveBeenCalledOnce();
446
+ expect(fn2).toHaveBeenCalledOnce();
447
+ });
448
+
449
+ it('clear() removes all handlers', () => {
450
+ const fn = vi.fn();
451
+ bus.on('a', fn);
452
+ bus.on('b', fn);
453
+ bus.clear();
454
+ bus.emit('a');
455
+ bus.emit('b');
456
+ expect(fn).not.toHaveBeenCalled();
457
+ });
458
+ });
459
+
301
460
  // ---------------------------------------------------------------------------
302
461
  // Event bus
303
462
  // ---------------------------------------------------------------------------
@@ -209,9 +209,20 @@ export class ZQueryCollection {
209
209
 
210
210
  /** Get `innerHTML` of the first element, or `undefined` if empty. */
211
211
  html(): string | undefined;
212
- /** Set `innerHTML` on all elements. */
212
+ /**
213
+ * Set content on all elements. Auto-morphs when the element already has
214
+ * children (preserves focus, scroll, form state, keyed reorder via LIS).
215
+ * Empty elements receive raw `innerHTML` for fast first-paint.
216
+ * Use `empty().html(content)` to force raw innerHTML.
217
+ */
213
218
  html(content: string): this;
214
219
 
220
+ /**
221
+ * Morph all elements' children to match new HTML using the diff engine.
222
+ * Always morphs regardless of whether the element already has children.
223
+ */
224
+ morph(content: string): this;
225
+
215
226
  /** Get `textContent` of the first element, or `undefined` if empty. */
216
227
  text(): string | undefined;
217
228
  /** Set `textContent` on all elements. */
@@ -248,7 +259,11 @@ export class ZQueryCollection {
248
259
  /** Clone elements (default: deep clone). */
249
260
  clone(deep?: boolean): ZQueryCollection;
250
261
 
251
- /** Replace elements with new content. */
262
+ /**
263
+ * Replace elements with new content. When given an HTML string with the
264
+ * same tag name, morphs the element in place (preserving identity and state).
265
+ * Falls back to full replacement when the tag name differs or content is a Node.
266
+ */
252
267
  replaceWith(content: string | Node): this;
253
268
 
254
269
  /** Insert every element in the collection at the end of the target. */
@@ -187,6 +187,13 @@ export function destroy(target: string | Element): void;
187
187
  /** Returns an object of all registered component definitions (for debugging). */
188
188
  export function getRegistry(): Record<string, ComponentDefinition>;
189
189
 
190
+ /**
191
+ * Pre-load external templates and styles for a registered component.
192
+ * Useful for warming the cache before navigation to avoid blank flashes.
193
+ * @param name Registered component name.
194
+ */
195
+ export function prefetch(name: string): Promise<void>;
196
+
190
197
  /** Handle returned by `$.style()`. */
191
198
  export interface StyleHandle {
192
199
  /** Remove all injected `<link>` elements. */
package/types/misc.d.ts CHANGED
@@ -14,12 +14,25 @@
14
14
  * positions, video playback, and other live DOM state.
15
15
  *
16
16
  * Use `z-key="uniqueId"` attributes on list items for keyed reconciliation.
17
+ * Elements with `id`, `data-id`, or `data-key` attributes are auto-keyed.
17
18
  *
18
19
  * @param rootEl The live DOM container to patch.
19
20
  * @param newHTML The desired HTML string.
20
21
  */
21
22
  export function morph(rootEl: Element, newHTML: string): void;
22
23
 
24
+ /**
25
+ * Morph a single element in place — diffs attributes and children
26
+ * without replacing the node reference. If the tag name matches, the
27
+ * element is patched in place (preserving identity). If the tag differs,
28
+ * the element is replaced.
29
+ *
30
+ * @param oldEl The live DOM element to patch.
31
+ * @param newHTML HTML string for the replacement element.
32
+ * @returns The resulting element (same ref if morphed, new if replaced).
33
+ */
34
+ export function morphElement(oldEl: Element, newHTML: string): Element;
35
+
23
36
  // ---------------------------------------------------------------------------
24
37
  // Safe Expression Evaluator
25
38
  // ---------------------------------------------------------------------------