zero-query 1.2.0 → 1.2.2

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.
@@ -226,6 +226,21 @@ describe('Router - z-link-params', () => {
226
226
  link.click();
227
227
  expect(window.location.hash).toBe('#/user/fallback');
228
228
  });
229
+
230
+ it('rejects z-link-params that parses to an array (must be an object)', () => {
231
+ document.body.innerHTML += '<a z-link="/user/:id" z-link-params=\'["42"]\'>User</a>';
232
+ const link = document.querySelector('a[z-link="/user/:id"]');
233
+ link.click();
234
+ // Interpolation skipped, raw href preserved
235
+ expect(window.location.hash).toBe('#/user/:id');
236
+ });
237
+
238
+ it('rejects z-link-params that parses to null', () => {
239
+ document.body.innerHTML += '<a z-link="/user/:id" z-link-params="null">User</a>';
240
+ const link = document.querySelector('a[z-link="/user/:id"]');
241
+ link.click();
242
+ expect(window.location.hash).toBe('#/user/:id');
243
+ });
229
244
  });
230
245
 
231
246
 
@@ -2325,3 +2340,84 @@ describe('Router - z-active-route', () => {
2325
2340
  expect(docs.classList.contains('active')).toBe(false);
2326
2341
  });
2327
2342
  });
2343
+
2344
+
2345
+ // ---------------------------------------------------------------------------
2346
+ // Keep-alive LRU cache
2347
+ // ---------------------------------------------------------------------------
2348
+
2349
+ describe('Router - keep-alive LRU', () => {
2350
+ beforeEach(() => {
2351
+ document.body.innerHTML = '<div id="app"></div>';
2352
+ window.location.hash = '#/';
2353
+ // Register fresh keep-alive components for each test
2354
+ component('ka-a', { render: () => '<p>a</p>' });
2355
+ component('ka-b', { render: () => '<p>b</p>' });
2356
+ component('ka-c', { render: () => '<p>c</p>' });
2357
+ component('ka-d', { render: () => '<p>d</p>' });
2358
+ });
2359
+
2360
+ it('keepAliveMax evicts oldest cached instance when exceeded', async () => {
2361
+ const router = createRouter({
2362
+ el: '#app',
2363
+ mode: 'hash',
2364
+ keepAliveMax: 2,
2365
+ routes: [
2366
+ { path: '/a', component: 'ka-a', keepAlive: true },
2367
+ { path: '/b', component: 'ka-b', keepAlive: true },
2368
+ { path: '/c', component: 'ka-c', keepAlive: true },
2369
+ ],
2370
+ });
2371
+
2372
+ router.navigate('/a'); await router._resolve();
2373
+ router.navigate('/b'); await router._resolve();
2374
+ expect(router._keepAliveCache.size).toBe(2);
2375
+ expect(router._keepAliveCache.has('ka-a')).toBe(true);
2376
+ expect(router._keepAliveCache.has('ka-b')).toBe(true);
2377
+
2378
+ router.navigate('/c'); await router._resolve();
2379
+ // ka-a (oldest, not current) should be evicted
2380
+ expect(router._keepAliveCache.size).toBe(2);
2381
+ expect(router._keepAliveCache.has('ka-a')).toBe(false);
2382
+ expect(router._keepAliveCache.has('ka-b')).toBe(true);
2383
+ expect(router._keepAliveCache.has('ka-c')).toBe(true);
2384
+ });
2385
+
2386
+ it('keepAliveMax default (unbounded) preserves all entries', async () => {
2387
+ const router = createRouter({
2388
+ el: '#app',
2389
+ mode: 'hash',
2390
+ routes: [
2391
+ { path: '/a', component: 'ka-a', keepAlive: true },
2392
+ { path: '/b', component: 'ka-b', keepAlive: true },
2393
+ { path: '/c', component: 'ka-c', keepAlive: true },
2394
+ { path: '/d', component: 'ka-d', keepAlive: true },
2395
+ ],
2396
+ });
2397
+ router.navigate('/a'); await router._resolve();
2398
+ router.navigate('/b'); await router._resolve();
2399
+ router.navigate('/c'); await router._resolve();
2400
+ router.navigate('/d'); await router._resolve();
2401
+ expect(router._keepAliveCache.size).toBe(4);
2402
+ });
2403
+
2404
+ it('revisiting a cached route refreshes LRU order', async () => {
2405
+ const router = createRouter({
2406
+ el: '#app',
2407
+ mode: 'hash',
2408
+ keepAliveMax: 2,
2409
+ routes: [
2410
+ { path: '/a', component: 'ka-a', keepAlive: true },
2411
+ { path: '/b', component: 'ka-b', keepAlive: true },
2412
+ { path: '/c', component: 'ka-c', keepAlive: true },
2413
+ ],
2414
+ });
2415
+ router.navigate('/a'); await router._resolve();
2416
+ router.navigate('/b'); await router._resolve();
2417
+ router.navigate('/a'); await router._resolve(); // touch a → now b is oldest
2418
+ router.navigate('/c'); await router._resolve(); // should evict b, not a
2419
+ expect(router._keepAliveCache.has('ka-a')).toBe(true);
2420
+ expect(router._keepAliveCache.has('ka-b')).toBe(false);
2421
+ expect(router._keepAliveCache.has('ka-c')).toBe(true);
2422
+ });
2423
+ });
package/tests/ssr.test.js CHANGED
@@ -806,6 +806,52 @@ describe('SSRApp - renderShell', () => {
806
806
  expect(html).not.toContain('data-zq-ssr');
807
807
  });
808
808
 
809
+ it('replaces meta description regardless of attribute spacing, order, or quoting', async () => {
810
+ const variantShell = `<!DOCTYPE html>
811
+ <html><head>
812
+ <title>T</title>
813
+ <meta name="description" content="orig multi-space">
814
+ </head><body><z-outlet></z-outlet></body></html>`;
815
+ const html = await app.renderShell(variantShell, { component: 'test-page', description: 'New' });
816
+ expect(html).toContain('<meta name="description" content="New">');
817
+ expect(html).not.toContain('orig multi-space');
818
+
819
+ const reversedShell = `<!DOCTYPE html>
820
+ <html><head>
821
+ <title>T</title>
822
+ <meta content="orig reversed" name="description">
823
+ </head><body><z-outlet></z-outlet></body></html>`;
824
+ const html2 = await app.renderShell(reversedShell, { component: 'test-page', description: 'New2' });
825
+ expect(html2).toContain('<meta name="description" content="New2">');
826
+ expect(html2).not.toContain('orig reversed');
827
+
828
+ const selfClosingShell = `<!DOCTYPE html>
829
+ <html><head>
830
+ <title>T</title>
831
+ <meta name='description' content='orig single quoted' />
832
+ </head><body><z-outlet></z-outlet></body></html>`;
833
+ const html3 = await app.renderShell(selfClosingShell, { component: 'test-page', description: 'New3' });
834
+ expect(html3).toContain('<meta name="description" content="New3">');
835
+ expect(html3).not.toContain('orig single quoted');
836
+ });
837
+
838
+ it('replaces og:* meta tags regardless of attribute order / quoting', async () => {
839
+ const variantShell = `<!DOCTYPE html>
840
+ <html><head>
841
+ <title>T</title>
842
+ <meta content="orig og title" property="og:title">
843
+ <meta property='og:type' content='website' />
844
+ </head><body><z-outlet></z-outlet></body></html>`;
845
+ const html = await app.renderShell(variantShell, {
846
+ component: 'test-page',
847
+ og: { title: 'New OG', type: 'article' },
848
+ });
849
+ expect(html).toContain('<meta property="og:title" content="New OG">');
850
+ expect(html).toContain('<meta property="og:type" content="article">');
851
+ expect(html).not.toContain('orig og title');
852
+ expect(html).not.toContain("content='website'");
853
+ });
854
+
809
855
  it('handles a missing component gracefully', async () => {
810
856
  const html = await app.renderShell(shell, { component: 'nonexistent' });
811
857
  expect(html).toContain('<!-- SSR error:');
@@ -232,6 +232,16 @@ describe('Store - snapshot & replaceState', () => {
232
232
  expect(store.state.a).toBe(1); // original unchanged
233
233
  });
234
234
 
235
+ it('snapshot({ clone: false }) skips the deep copy (read-only fast path)', () => {
236
+ const store = createStore('snap-noclone', { state: { a: 1, nested: { b: 2 } } });
237
+ const snap = store.snapshot({ clone: false });
238
+ // Same reference to the underlying nested object (no cloning happened)
239
+ expect(snap.nested).toBe((store.state.__raw || store.state).nested);
240
+ // Default still clones
241
+ const cloned = store.snapshot();
242
+ expect(cloned.nested).not.toBe((store.state.__raw || store.state).nested);
243
+ });
244
+
235
245
  it('replaceState replaces entire state', () => {
236
246
  const store = createStore('replace-1', { state: { x: 1, y: 2 } });
237
247
  store.replaceState({ x: 10, z: 30 });
@@ -62,6 +62,27 @@ describe('debounce', () => {
62
62
  vi.advanceTimersByTime(20);
63
63
  expect(fn).toHaveBeenCalledOnce();
64
64
  });
65
+
66
+ it('cancels pending invocation when AbortSignal aborts', () => {
67
+ const fn = vi.fn();
68
+ const ctrl = new AbortController();
69
+ const debounced = debounce(fn, 100, { signal: ctrl.signal });
70
+ debounced('x');
71
+ vi.advanceTimersByTime(50);
72
+ ctrl.abort();
73
+ vi.advanceTimersByTime(200);
74
+ expect(fn).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('ignores calls after AbortSignal aborts', () => {
78
+ const fn = vi.fn();
79
+ const ctrl = new AbortController();
80
+ const debounced = debounce(fn, 100, { signal: ctrl.signal });
81
+ ctrl.abort();
82
+ debounced('x');
83
+ vi.advanceTimersByTime(200);
84
+ expect(fn).not.toHaveBeenCalled();
85
+ });
65
86
  });
66
87
 
67
88
 
@@ -594,6 +615,19 @@ describe('bus (EventBus)', () => {
594
615
  // ===========================================================================
595
616
 
596
617
  describe('throttle - edge cases', () => {
618
+ it('cancels trailing call when AbortSignal aborts', () => {
619
+ vi.useFakeTimers();
620
+ const fn = vi.fn();
621
+ const ctrl = new AbortController();
622
+ const throttled = throttle(fn, 100, { signal: ctrl.signal });
623
+ throttled('a');
624
+ expect(fn).toHaveBeenCalledTimes(1);
625
+ throttled('b');
626
+ ctrl.abort();
627
+ vi.advanceTimersByTime(200);
628
+ expect(fn).toHaveBeenCalledTimes(1);
629
+ });
630
+
597
631
  it('fires trailing call after wait period', async () => {
598
632
  vi.useFakeTimers();
599
633
  const fn = vi.fn();
@@ -1,252 +0,0 @@
1
- // lib/room.js - Backend-less WebRTC room built on BroadcastChannel.
2
- //
3
- // Why no backend?
4
- // WebRTC needs a signaling channel to swap SDP + ICE between peers. We use
5
- // BroadcastChannel, which lets every same-origin tab on the same browser
6
- // talk to every other tab for free. Open the demo in 2+ tabs (or windows)
7
- // and you have a working mesh room with audio, video, screen share, and
8
- // text chat - zero servers required.
9
- //
10
- // Caveats:
11
- // - BroadcastChannel is same-origin / same-browser only. To connect across
12
- // machines, plug in any real signaling transport (WebSocket, SSE, etc.)
13
- // in place of BroadcastChannel and the rest of this file keeps working.
14
- // - Mesh topology: every peer has an RTCPeerConnection with every other
15
- // peer. Works great up to ~6 peers; beyond that, use an SFU.
16
-
17
- const SIGNAL_VERSION = 1;
18
-
19
- /**
20
- * Wrap a BroadcastChannel as a "SignalingClient" that $.Peer can consume.
21
- * The Peer class expects `.send(type, payload)` and `.on(type, cb)`.
22
- */
23
- function makeSignaling(channel, myId) {
24
- return {
25
- send(type, payload) {
26
- channel.postMessage({ v: SIGNAL_VERSION, type, from: myId, ...payload });
27
- },
28
- on(type, cb) {
29
- const handler = (ev) => {
30
- const msg = ev.data;
31
- if (!msg || msg.v !== SIGNAL_VERSION) return;
32
- if (msg.type !== type) return;
33
- if (msg.from === myId) return; // ignore self
34
- if (msg.to !== undefined && msg.to !== myId) return; // not addressed to us
35
- cb(msg);
36
- };
37
- channel.addEventListener('message', handler);
38
- return () => channel.removeEventListener('message', handler);
39
- },
40
- };
41
- }
42
-
43
- /**
44
- * Backend-less mesh room.
45
- *
46
- * Events (subscribe via `.on(type, cb)`):
47
- * - 'peers' payload: Map<id, PeerInfo> - roster changed
48
- * - 'chat' payload: { from, name, text, t }
49
- * - 'status' payload: string - human-readable status line
50
- * - 'error' payload: Error
51
- *
52
- * PeerInfo: { id, name, stream: MediaStream|null, peer: $.Peer, chat: RTCDataChannel|null }
53
- */
54
- export class LocalRoom {
55
- constructor(name, { id, displayName, iceServers = [] } = {}) {
56
- this.name = name;
57
- this.id = id || ('p-' + Math.random().toString(36).slice(2, 10));
58
- this.displayName = displayName || ('User-' + this.id.slice(-4));
59
- this.iceServers = iceServers;
60
-
61
- this._channel = null;
62
- this._signaling = null;
63
- this._peers = new Map(); // id -> PeerInfo
64
- this._localStream = null;
65
- this._videoSenders = new Map(); // peerId -> RTCRtpSender (current video sender)
66
- this._listeners = new Map();
67
- this._heartbeat = null;
68
- this._unsubHello = null;
69
- this._unsubBye = null;
70
- this.closed = false;
71
- }
72
-
73
- // ---- Pub/sub ---------------------------------------------------------
74
-
75
- on(type, cb) {
76
- if (typeof cb !== 'function') return () => {};
77
- let set = this._listeners.get(type);
78
- if (!set) { set = new Set(); this._listeners.set(type, set); }
79
- set.add(cb);
80
- return () => set.delete(cb);
81
- }
82
-
83
- _emit(type, payload) {
84
- const set = this._listeners.get(type);
85
- if (!set) return;
86
- for (const cb of [...set]) { try { cb(payload); } catch (_) {} }
87
- }
88
-
89
- get peers() { return this._peers; }
90
-
91
- // ---- Lifecycle -------------------------------------------------------
92
-
93
- /**
94
- * Open the BroadcastChannel, announce presence, and start accepting peers.
95
- * `localStream` is optional - join as a viewer if you have no camera/mic.
96
- */
97
- join(localStream = null) {
98
- if (this.closed) throw new Error('LocalRoom already closed');
99
- this._localStream = localStream;
100
-
101
- this._channel = new BroadcastChannel('zquery-room::' + this.name);
102
- this._signaling = makeSignaling(this._channel, this.id);
103
-
104
- // Listen for newcomers' hellos and respond with our own hello so the
105
- // newcomer learns about us too. Net effect: every pair exchanges
106
- // hellos exactly once and both sides bring up a peer connection.
107
- this._unsubHello = this._signaling.on('hello', (m) => {
108
- // Ignore if we already track this peer.
109
- if (this._peers.has(m.from)) return;
110
- this._addPeer(m.from, m.name || 'User');
111
- // Reply directly so the newcomer adds us.
112
- this._signaling.send('hello', { to: m.from, name: this.displayName });
113
- });
114
-
115
- this._unsubBye = this._signaling.on('bye', (m) => {
116
- this._removePeer(m.from);
117
- });
118
-
119
- // Broadcast hello to the whole room (no `to` field = everyone).
120
- this._signaling.send('hello', { name: this.displayName });
121
- this._emit('status', 'Looking for peers in room "' + this.name + '"...');
122
-
123
- // Periodic hello so late-comers refresh the roster even if they miss
124
- // the first broadcast (e.g. tab woken from background).
125
- this._heartbeat = setInterval(() => {
126
- if (this.closed) return;
127
- this._signaling.send('hello', { name: this.displayName });
128
- }, 5000);
129
-
130
- return this;
131
- }
132
-
133
- leave() {
134
- if (this.closed) return;
135
- this.closed = true;
136
-
137
- if (this._heartbeat) { clearInterval(this._heartbeat); this._heartbeat = null; }
138
- if (this._unsubHello) { this._unsubHello(); this._unsubHello = null; }
139
- if (this._unsubBye) { this._unsubBye(); this._unsubBye = null; }
140
-
141
- try { this._signaling && this._signaling.send('bye', {}); } catch (_) {}
142
-
143
- for (const info of this._peers.values()) {
144
- try { info.peer.close(); } catch (_) {}
145
- }
146
- this._peers.clear();
147
- this._videoSenders.clear();
148
- this._emit('peers', this._peers);
149
-
150
- if (this._channel) {
151
- try { this._channel.close(); } catch (_) {}
152
- this._channel = null;
153
- }
154
- }
155
-
156
- // ---- Media control ---------------------------------------------------
157
-
158
- /**
159
- * Replace our outgoing video track on every peer (used for screen share).
160
- * Pass `null` to remove video entirely (camera-off).
161
- */
162
- async replaceVideoTrack(newTrack) {
163
- for (const [id, sender] of this._videoSenders) {
164
- try { await sender.replaceTrack(newTrack); }
165
- catch (err) { this._emit('error', err); }
166
- }
167
- }
168
-
169
- /** Broadcast a chat message over every peer's data channel. */
170
- sendChat(text) {
171
- const msg = { from: this.id, name: this.displayName, text, t: Date.now() };
172
- const payload = JSON.stringify(msg);
173
- for (const info of this._peers.values()) {
174
- const dc = info.chat;
175
- if (dc && dc.readyState === 'open') {
176
- try { dc.send(payload); } catch (_) {}
177
- }
178
- }
179
- // Echo locally so the sender sees their own message.
180
- this._emit('chat', msg);
181
- }
182
-
183
- // ---- Peer plumbing ---------------------------------------------------
184
-
185
- _addPeer(remoteId, remoteName) {
186
- // Perfect-negotiation politeness: the peer with the larger id is polite.
187
- const polite = this.id > remoteId;
188
- const peer = new window.$.Peer(remoteId, this._signaling, {
189
- polite,
190
- iceServers: this.iceServers,
191
- });
192
-
193
- /** @type {PeerInfo} */
194
- const info = { id: remoteId, name: remoteName, stream: null, peer, chat: null };
195
- this._peers.set(remoteId, info);
196
-
197
- // Collect remote tracks into a single MediaStream per peer.
198
- peer.on('track', (ev) => {
199
- const stream = ev.streams && ev.streams[0]
200
- ? ev.streams[0]
201
- : (info.stream || new MediaStream([ev.track]));
202
- info.stream = stream;
203
- this._emit('peers', this._peers);
204
- });
205
-
206
- // The peer with the smaller id opens the chat channel so we only get
207
- // one per pair. The other side picks it up via 'datachannel'.
208
- if (this.id < remoteId) {
209
- const dc = peer.createDataChannel('chat');
210
- this._wireChat(info, dc);
211
- } else {
212
- peer.on('datachannel', (ev) => this._wireChat(info, ev.channel));
213
- }
214
-
215
- peer.on('connectionstatechange', (state) => {
216
- if (state === 'failed' || state === 'closed') this._removePeer(remoteId);
217
- });
218
-
219
- peer.on('error', (err) => this._emit('error', err));
220
-
221
- // Publish local tracks (camera + mic) so the negotiation fires.
222
- if (this._localStream) {
223
- for (const track of this._localStream.getTracks()) {
224
- const sender = peer.addTrack(track, this._localStream);
225
- if (track.kind === 'video') this._videoSenders.set(remoteId, sender);
226
- }
227
- }
228
-
229
- this._emit('peers', this._peers);
230
- this._emit('status', 'Peer joined: ' + remoteName);
231
- }
232
-
233
- _wireChat(info, dc) {
234
- info.chat = dc;
235
- dc.onmessage = (ev) => {
236
- try { this._emit('chat', JSON.parse(ev.data)); }
237
- catch (_) { /* ignore malformed */ }
238
- };
239
- dc.onopen = () => this._emit('status', info.name + ' is now connected.');
240
- dc.onclose = () => { if (info.chat === dc) info.chat = null; };
241
- }
242
-
243
- _removePeer(remoteId) {
244
- const info = this._peers.get(remoteId);
245
- if (!info) return;
246
- try { info.peer.close(); } catch (_) {}
247
- this._peers.delete(remoteId);
248
- this._videoSenders.delete(remoteId);
249
- this._emit('peers', this._peers);
250
- this._emit('status', 'Peer left: ' + info.name);
251
- }
252
- }