ripple 0.2.105 → 0.2.106

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.
@@ -0,0 +1,954 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mount, flushSync, track } from 'ripple';
3
+ import { TrackedURL } from '../../src/runtime/url.js';
4
+
5
+ describe('TrackedURL', () => {
6
+ let container;
7
+
8
+ function render(component) {
9
+ mount(component, {
10
+ target: container
11
+ });
12
+ }
13
+
14
+ beforeEach(() => {
15
+ container = document.createElement('div');
16
+ document.body.appendChild(container);
17
+ });
18
+
19
+ afterEach(() => {
20
+ document.body.removeChild(container);
21
+ container = null;
22
+ });
23
+
24
+ it('creates URL from string with reactivity', () => {
25
+ component URLTest() {
26
+ const url = new TrackedURL('https://example.com:8080/path?foo=bar#section');
27
+
28
+ <pre>{url.href}</pre>
29
+ <pre>{url.protocol}</pre>
30
+ <pre>{url.hostname}</pre>
31
+ <pre>{url.port}</pre>
32
+ <pre>{url.pathname}</pre>
33
+ <pre>{url.search}</pre>
34
+ <pre>{url.hash}</pre>
35
+ }
36
+
37
+ render(URLTest);
38
+
39
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com:8080/path?foo=bar#section');
40
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https:');
41
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('example.com');
42
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('8080');
43
+ expect(container.querySelectorAll('pre')[4].textContent).toBe('/path');
44
+ expect(container.querySelectorAll('pre')[5].textContent).toBe('?foo=bar');
45
+ expect(container.querySelectorAll('pre')[6].textContent).toBe('#section');
46
+ });
47
+
48
+ it('creates URL from string with base URL', () => {
49
+ component URLTest() {
50
+ const url = new TrackedURL('/path?query=value', 'https://example.com');
51
+
52
+ <pre>{url.href}</pre>
53
+ <pre>{url.origin}</pre>
54
+ }
55
+
56
+ render(URLTest);
57
+
58
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?query=value');
59
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https://example.com');
60
+ });
61
+
62
+ it('handles protocol changes with reactivity', () => {
63
+ component URLTest() {
64
+ const url = new TrackedURL('https://example.com/path');
65
+
66
+ <button onClick={() => url.protocol = 'http:'}>{'Change Protocol'}</button>
67
+ <pre>{url.href}</pre>
68
+ <pre>{url.protocol}</pre>
69
+ <pre>{url.origin}</pre>
70
+ }
71
+
72
+ render(URLTest);
73
+
74
+ const button = container.querySelector('button');
75
+
76
+ // Initial state
77
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path');
78
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https:');
79
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('https://example.com');
80
+
81
+ // Change protocol
82
+ button.click();
83
+ flushSync();
84
+
85
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('http://example.com/path');
86
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('http:');
87
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('http://example.com');
88
+ });
89
+
90
+ it('handles hostname changes with reactivity', () => {
91
+ component URLTest() {
92
+ const url = new TrackedURL('https://example.com/path');
93
+
94
+ <button onClick={() => url.hostname = 'newdomain.com'}>{'Change Hostname'}</button>
95
+ <pre>{url.href}</pre>
96
+ <pre>{url.hostname}</pre>
97
+ <pre>{url.host}</pre>
98
+ }
99
+
100
+ render(URLTest);
101
+
102
+ const button = container.querySelector('button');
103
+
104
+ // Initial state
105
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path');
106
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('example.com');
107
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('example.com');
108
+
109
+ // Change hostname
110
+ button.click();
111
+ flushSync();
112
+
113
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://newdomain.com/path');
114
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('newdomain.com');
115
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('newdomain.com');
116
+ });
117
+
118
+ it('handles port changes with reactivity', () => {
119
+ component URLTest() {
120
+ const url = new TrackedURL('https://example.com:8080/path');
121
+
122
+ <button onClick={() => url.port = '9090'}>{'Change Port'}</button>
123
+ <pre>{url.href}</pre>
124
+ <pre>{url.port}</pre>
125
+ <pre>{url.host}</pre>
126
+ }
127
+
128
+ render(URLTest);
129
+
130
+ const button = container.querySelector('button');
131
+
132
+ // Initial state
133
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com:8080/path');
134
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('8080');
135
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('example.com:8080');
136
+
137
+ // Change port
138
+ button.click();
139
+ flushSync();
140
+
141
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com:9090/path');
142
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('9090');
143
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('example.com:9090');
144
+ });
145
+
146
+ it('handles host changes with reactivity', () => {
147
+ component URLTest() {
148
+ const url = new TrackedURL('https://example.com:8080/path');
149
+
150
+ <button onClick={() => url.host = 'newdomain.com:9090'}>{'Change Host'}</button>
151
+ <pre>{url.href}</pre>
152
+ <pre>{url.host}</pre>
153
+ <pre>{url.hostname}</pre>
154
+ <pre>{url.port}</pre>
155
+ }
156
+
157
+ render(URLTest);
158
+
159
+ const button = container.querySelector('button');
160
+
161
+ // Initial state
162
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com:8080/path');
163
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('example.com:8080');
164
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('example.com');
165
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('8080');
166
+
167
+ // Change host
168
+ button.click();
169
+ flushSync();
170
+
171
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://newdomain.com:9090/path');
172
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('newdomain.com:9090');
173
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('newdomain.com');
174
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('9090');
175
+ });
176
+
177
+ it('handles pathname changes with reactivity', () => {
178
+ component URLTest() {
179
+ const url = new TrackedURL('https://example.com/old-path');
180
+
181
+ <button onClick={() => url.pathname = '/new-path'}>{'Change Pathname'}</button>
182
+ <pre>{url.href}</pre>
183
+ <pre>{url.pathname}</pre>
184
+ }
185
+
186
+ render(URLTest);
187
+
188
+ const button = container.querySelector('button');
189
+
190
+ // Initial state
191
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/old-path');
192
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('/old-path');
193
+
194
+ // Change pathname
195
+ button.click();
196
+ flushSync();
197
+
198
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/new-path');
199
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('/new-path');
200
+ });
201
+
202
+ it('handles search changes with reactivity', () => {
203
+ component URLTest() {
204
+ const url = new TrackedURL('https://example.com/path?foo=bar');
205
+
206
+ <button onClick={() => url.search = '?baz=qux'}>{'Change Search'}</button>
207
+ <pre>{url.href}</pre>
208
+ <pre>{url.search}</pre>
209
+ }
210
+
211
+ render(URLTest);
212
+
213
+ const button = container.querySelector('button');
214
+
215
+ // Initial state
216
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=bar');
217
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?foo=bar');
218
+
219
+ // Change search
220
+ button.click();
221
+ flushSync();
222
+
223
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?baz=qux');
224
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?baz=qux');
225
+ });
226
+
227
+ it('handles hash changes with reactivity', () => {
228
+ component URLTest() {
229
+ const url = new TrackedURL('https://example.com/path#section1');
230
+
231
+ <button onClick={() => url.hash = '#section2'}>{'Change Hash'}</button>
232
+ <pre>{url.href}</pre>
233
+ <pre>{url.hash}</pre>
234
+ }
235
+
236
+ render(URLTest);
237
+
238
+ const button = container.querySelector('button');
239
+
240
+ // Initial state
241
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path#section1');
242
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('#section1');
243
+
244
+ // Change hash
245
+ button.click();
246
+ flushSync();
247
+
248
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path#section2');
249
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('#section2');
250
+ });
251
+
252
+ it('handles username changes with reactivity', () => {
253
+ component URLTest() {
254
+ const url = new TrackedURL('https://user:pass@example.com/path');
255
+
256
+ <button onClick={() => url.username = 'newuser'}>{'Change Username'}</button>
257
+ <pre>{url.href}</pre>
258
+ <pre>{url.username}</pre>
259
+ }
260
+
261
+ render(URLTest);
262
+
263
+ const button = container.querySelector('button');
264
+
265
+ // Initial state
266
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://user:pass@example.com/path');
267
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('user');
268
+
269
+ // Change username
270
+ button.click();
271
+ flushSync();
272
+
273
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://newuser:pass@example.com/path');
274
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('newuser');
275
+ });
276
+
277
+ it('handles password changes with reactivity', () => {
278
+ component URLTest() {
279
+ const url = new TrackedURL('https://user:pass@example.com/path');
280
+
281
+ <button onClick={() => url.password = 'newpass'}>{'Change Password'}</button>
282
+ <pre>{url.href}</pre>
283
+ <pre>{url.password}</pre>
284
+ }
285
+
286
+ render(URLTest);
287
+
288
+ const button = container.querySelector('button');
289
+
290
+ // Initial state
291
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://user:pass@example.com/path');
292
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('pass');
293
+
294
+ // Change password
295
+ button.click();
296
+ flushSync();
297
+
298
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://user:newpass@example.com/path');
299
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('newpass');
300
+ });
301
+
302
+ it('handles href changes with reactivity', () => {
303
+ component URLTest() {
304
+ const url = new TrackedURL('https://example.com/path?foo=bar#section');
305
+
306
+ <button onClick={() => url.href = 'https://newdomain.com:9090/newpath?baz=qux#newsection'}>{'Change Href'}</button>
307
+ <pre>{url.href}</pre>
308
+ <pre>{url.protocol}</pre>
309
+ <pre>{url.hostname}</pre>
310
+ <pre>{url.port}</pre>
311
+ <pre>{url.pathname}</pre>
312
+ <pre>{url.search}</pre>
313
+ <pre>{url.hash}</pre>
314
+ }
315
+
316
+ render(URLTest);
317
+
318
+ const button = container.querySelector('button');
319
+
320
+ // Initial state
321
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=bar#section');
322
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https:');
323
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('example.com');
324
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('');
325
+ expect(container.querySelectorAll('pre')[4].textContent).toBe('/path');
326
+ expect(container.querySelectorAll('pre')[5].textContent).toBe('?foo=bar');
327
+ expect(container.querySelectorAll('pre')[6].textContent).toBe('#section');
328
+
329
+ // Change href
330
+ button.click();
331
+ flushSync();
332
+
333
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://newdomain.com:9090/newpath?baz=qux#newsection');
334
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https:');
335
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('newdomain.com');
336
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('9090');
337
+ expect(container.querySelectorAll('pre')[4].textContent).toBe('/newpath');
338
+ expect(container.querySelectorAll('pre')[5].textContent).toBe('?baz=qux');
339
+ expect(container.querySelectorAll('pre')[6].textContent).toBe('#newsection');
340
+ });
341
+
342
+ it('handles origin property reactivity', () => {
343
+ component URLTest() {
344
+ const url = new TrackedURL('https://example.com:8080/path');
345
+
346
+ <button onClick={() => url.protocol = 'http:'}>{'Change Protocol'}</button>
347
+ <button onClick={() => url.hostname = 'newdomain.com'}>{'Change Hostname'}</button>
348
+ <button onClick={() => url.port = '9090'}>{'Change Port'}</button>
349
+ <pre>{url.origin}</pre>
350
+ }
351
+
352
+ render(URLTest);
353
+
354
+ const buttons = container.querySelectorAll('button');
355
+
356
+ // Initial state
357
+ expect(container.querySelector('pre').textContent).toBe('https://example.com:8080');
358
+
359
+ // Change protocol
360
+ buttons[0].click();
361
+ flushSync();
362
+ expect(container.querySelector('pre').textContent).toBe('http://example.com:8080');
363
+
364
+ // Change hostname
365
+ buttons[1].click();
366
+ flushSync();
367
+ expect(container.querySelector('pre').textContent).toBe('http://newdomain.com:8080');
368
+
369
+ // Change port
370
+ buttons[2].click();
371
+ flushSync();
372
+ expect(container.querySelector('pre').textContent).toBe('http://newdomain.com:9090');
373
+ });
374
+
375
+ it('handles searchParams changes with reactivity', () => {
376
+ component URLTest() {
377
+ const url = new TrackedURL('https://example.com/path?foo=bar');
378
+ const params = url.searchParams;
379
+
380
+ <button onClick={() => params.set('foo', 'updated')}>{'Update Foo'}</button>
381
+ <button onClick={() => params.append('baz', 'qux')}>{'Add Baz'}</button>
382
+ <pre>{url.href}</pre>
383
+ <pre>{url.search}</pre>
384
+ <pre>{params.get('foo')}</pre>
385
+ }
386
+
387
+ render(URLTest);
388
+
389
+ const updateButton = container.querySelectorAll('button')[0];
390
+ const addButton = container.querySelectorAll('button')[1];
391
+
392
+ // Initial state
393
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=bar');
394
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?foo=bar');
395
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('bar');
396
+
397
+ // Update param
398
+ updateButton.click();
399
+ flushSync();
400
+
401
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=updated');
402
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?foo=updated');
403
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('updated');
404
+
405
+ // Add param
406
+ addButton.click();
407
+ flushSync();
408
+
409
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=updated&baz=qux');
410
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?foo=updated&baz=qux');
411
+ });
412
+
413
+ it('handles search property updates reflected in searchParams', () => {
414
+ component URLTest() {
415
+ const url = new TrackedURL('https://example.com/path?foo=bar');
416
+ const params = url.searchParams;
417
+
418
+ <button onClick={() => url.search = '?baz=qux&test=value'}>{'Change Search'}</button>
419
+ <pre>{url.search}</pre>
420
+ <pre>{params.get('foo')}</pre>
421
+ <pre>{params.get('baz')}</pre>
422
+ <pre>{params.get('test')}</pre>
423
+ <pre>{params.size}</pre>
424
+ }
425
+
426
+ render(URLTest);
427
+
428
+ const button = container.querySelector('button');
429
+
430
+ // Initial state
431
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('?foo=bar');
432
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('bar');
433
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('');
434
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('');
435
+ expect(container.querySelectorAll('pre')[4].textContent).toBe('1');
436
+
437
+ // Change search
438
+ button.click();
439
+ flushSync();
440
+
441
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('?baz=qux&test=value');
442
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('');
443
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('qux');
444
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('value');
445
+ expect(container.querySelectorAll('pre')[4].textContent).toBe('2');
446
+ });
447
+
448
+ it('handles toString method', () => {
449
+ component URLTest() {
450
+ const url = new TrackedURL('https://example.com/path?foo=bar#section');
451
+
452
+ <button onClick={() => url.pathname = '/newpath'}>{'Change Pathname'}</button>
453
+ <pre>{url.toString()}</pre>
454
+ }
455
+
456
+ render(URLTest);
457
+
458
+ const button = container.querySelector('button');
459
+
460
+ // Initial state
461
+ expect(container.querySelector('pre').textContent).toBe('https://example.com/path?foo=bar#section');
462
+
463
+ // Change pathname
464
+ button.click();
465
+ flushSync();
466
+
467
+ expect(container.querySelector('pre').textContent).toBe('https://example.com/newpath?foo=bar#section');
468
+ });
469
+
470
+ it('handles toJSON method', () => {
471
+ component URLTest() {
472
+ const url = new TrackedURL('https://example.com/path?foo=bar');
473
+
474
+ <button onClick={() => url.pathname = '/api'}>{'Change Pathname'}</button>
475
+ <pre>{url.toJSON()}</pre>
476
+ <pre>{JSON.stringify({ url: url.toJSON() })}</pre>
477
+ }
478
+
479
+ render(URLTest);
480
+
481
+ const button = container.querySelector('button');
482
+
483
+ // Initial state
484
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=bar');
485
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('{"url":"https://example.com/path?foo=bar"}');
486
+
487
+ // Change pathname
488
+ button.click();
489
+ flushSync();
490
+
491
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/api?foo=bar');
492
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('{"url":"https://example.com/api?foo=bar"}');
493
+ });
494
+
495
+ it('handles multiple URL property changes in sequence', () => {
496
+ component URLTest() {
497
+ const url = new TrackedURL('https://example.com/path');
498
+
499
+ <button onClick={() => {
500
+ url.protocol = 'http:';
501
+ url.hostname = 'newdomain.com';
502
+ url.port = '8080';
503
+ url.pathname = '/api';
504
+ url.search = '?key=value';
505
+ url.hash = '#section';
506
+ }}>{'Change All'}</button>
507
+ <pre>{url.href}</pre>
508
+ }
509
+
510
+ render(URLTest);
511
+
512
+ const button = container.querySelector('button');
513
+
514
+ // Initial state
515
+ expect(container.querySelector('pre').textContent).toBe('https://example.com/path');
516
+
517
+ // Change all properties
518
+ button.click();
519
+ flushSync();
520
+
521
+ expect(container.querySelector('pre').textContent).toBe('http://newdomain.com:8080/api?key=value#section');
522
+ });
523
+
524
+ it('handles URL with no port specified', () => {
525
+ component URLTest() {
526
+ const url = new TrackedURL('https://example.com/path');
527
+
528
+ <pre>{url.port}</pre>
529
+ <pre>{url.host}</pre>
530
+ <button onClick={() => url.port = '8080'}>{'Add Port'}</button>
531
+ }
532
+
533
+ render(URLTest);
534
+
535
+ const button = container.querySelector('button');
536
+
537
+ // Initial state - default ports are empty strings
538
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('');
539
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('example.com');
540
+
541
+ // Add port
542
+ button.click();
543
+ flushSync();
544
+
545
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('8080');
546
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('example.com:8080');
547
+ });
548
+
549
+ it('handles URL with no search params', () => {
550
+ component URLTest() {
551
+ const url = new TrackedURL('https://example.com/path');
552
+
553
+ <pre>{url.search}</pre>
554
+ <pre>{url.searchParams.size}</pre>
555
+ <button onClick={() => url.searchParams.append('foo', 'bar')}>{'Add Param'}</button>
556
+ }
557
+
558
+ render(URLTest);
559
+
560
+ const button = container.querySelector('button');
561
+
562
+ // Initial state
563
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('');
564
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('0');
565
+
566
+ // Add param
567
+ button.click();
568
+ flushSync();
569
+
570
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('?foo=bar');
571
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('1');
572
+ });
573
+
574
+ it('handles URL with no hash', () => {
575
+ component URLTest() {
576
+ const url = new TrackedURL('https://example.com/path');
577
+
578
+ <pre>{url.hash}</pre>
579
+ <button onClick={() => url.hash = '#section'}>{'Add Hash'}</button>
580
+ }
581
+
582
+ render(URLTest);
583
+
584
+ const button = container.querySelector('button');
585
+
586
+ // Initial state
587
+ expect(container.querySelector('pre').textContent).toBe('');
588
+
589
+ // Add hash
590
+ button.click();
591
+ flushSync();
592
+
593
+ expect(container.querySelector('pre').textContent).toBe('#section');
594
+ });
595
+
596
+ it('handles removing port by setting empty string', () => {
597
+ component URLTest() {
598
+ const url = new TrackedURL('https://example.com:8080/path');
599
+
600
+ <button onClick={() => url.port = ''}>{'Remove Port'}</button>
601
+ <pre>{url.href}</pre>
602
+ <pre>{url.port}</pre>
603
+ }
604
+
605
+ render(URLTest);
606
+
607
+ const button = container.querySelector('button');
608
+
609
+ // Initial state
610
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com:8080/path');
611
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('8080');
612
+
613
+ // Remove port
614
+ button.click();
615
+ flushSync();
616
+
617
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path');
618
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('');
619
+ });
620
+
621
+ it('handles removing hash by setting empty string', () => {
622
+ component URLTest() {
623
+ const url = new TrackedURL('https://example.com/path#section');
624
+
625
+ <button onClick={() => url.hash = ''}>{'Remove Hash'}</button>
626
+ <pre>{url.href}</pre>
627
+ <pre>{url.hash}</pre>
628
+ }
629
+
630
+ render(URLTest);
631
+
632
+ const button = container.querySelector('button');
633
+
634
+ // Initial state
635
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path#section');
636
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('#section');
637
+
638
+ // Remove hash
639
+ button.click();
640
+ flushSync();
641
+
642
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path');
643
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('');
644
+ });
645
+
646
+ it('handles removing search by setting empty string', () => {
647
+ component URLTest() {
648
+ const url = new TrackedURL('https://example.com/path?foo=bar');
649
+
650
+ <button onClick={() => url.search = ''}>{'Remove Search'}</button>
651
+ <pre>{url.href}</pre>
652
+ <pre>{url.search}</pre>
653
+ <pre>{url.searchParams.size}</pre>
654
+ }
655
+
656
+ render(URLTest);
657
+
658
+ const button = container.querySelector('button');
659
+
660
+ // Initial state
661
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?foo=bar');
662
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?foo=bar');
663
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('1');
664
+
665
+ // Remove search
666
+ button.click();
667
+ flushSync();
668
+
669
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path');
670
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('');
671
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('0');
672
+ });
673
+
674
+ it('handles reactive computed properties based on URL', () => {
675
+ component URLTest() {
676
+ const url = new TrackedURL('https://example.com/users/123?tab=profile');
677
+ let userId = track(() => url.pathname.split('/').pop());
678
+ let activeTab = track(() => url.searchParams.get('tab'));
679
+
680
+ <button onClick={() => url.pathname = '/users/456'}>{'Change User'}</button>
681
+ <button onClick={() => url.searchParams.set('tab', 'settings')}>{'Change Tab'}</button>
682
+ <pre>{`User ID: ${@userId}`}</pre>
683
+ <pre>{`Active Tab: ${@activeTab}`}</pre>
684
+ }
685
+
686
+ render(URLTest);
687
+
688
+ const changeUserBtn = container.querySelectorAll('button')[0];
689
+ const changeTabBtn = container.querySelectorAll('button')[1];
690
+
691
+ // Initial state
692
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('User ID: 123');
693
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('Active Tab: profile');
694
+
695
+ // Change user
696
+ changeUserBtn.click();
697
+ flushSync();
698
+
699
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('User ID: 456');
700
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('Active Tab: profile');
701
+
702
+ // Change tab
703
+ changeTabBtn.click();
704
+ flushSync();
705
+
706
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('User ID: 456');
707
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('Active Tab: settings');
708
+ });
709
+
710
+ it('handles URL encoding correctly', () => {
711
+ component URLTest() {
712
+ const url = new TrackedURL('https://example.com/path with spaces?key=value with spaces');
713
+
714
+ <pre>{url.pathname}</pre>
715
+ <pre>{url.search}</pre>
716
+ <pre>{url.href}</pre>
717
+ }
718
+
719
+ render(URLTest);
720
+
721
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('/path%20with%20spaces');
722
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('?key=value%20with%20spaces');
723
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('https://example.com/path%20with%20spaces?key=value%20with%20spaces');
724
+ });
725
+
726
+ it('maintains reactivity across multiple components', () => {
727
+ component ParentTest() {
728
+ const url = new TrackedURL('https://example.com/path?count=0');
729
+
730
+ <ChildA url={url} />
731
+ <ChildB url={url} />
732
+ }
733
+
734
+ component ChildA({ url }) {
735
+ <button onClick={() => {
736
+ const current = parseInt(url.searchParams.get('count') || '0', 10);
737
+ url.searchParams.set('count', String(current + 1));
738
+ }}>{'Increment Count'}</button>
739
+ }
740
+
741
+ component ChildB({ url }) {
742
+ let count = track(() => url.searchParams.get('count'));
743
+
744
+ <pre>{url.href}</pre>
745
+ <pre>{@count}</pre>
746
+ }
747
+
748
+ render(ParentTest);
749
+
750
+ const button = container.querySelector('button');
751
+
752
+ // Initial state
753
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?count=0');
754
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('0');
755
+
756
+ // Increment from child
757
+ button.click();
758
+ flushSync();
759
+
760
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?count=1');
761
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('1');
762
+
763
+ button.click();
764
+ flushSync();
765
+
766
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/path?count=2');
767
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('2');
768
+ });
769
+
770
+ it('handles relative URL paths correctly', () => {
771
+ component URLTest() {
772
+ const url = new TrackedURL('../sibling/path', 'https://example.com/parent/current');
773
+
774
+ <pre>{url.href}</pre>
775
+ <pre>{url.pathname}</pre>
776
+ }
777
+
778
+ render(URLTest);
779
+
780
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://example.com/sibling/path');
781
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('/sibling/path');
782
+ });
783
+
784
+ it('handles URL with IPv4 address', () => {
785
+ component URLTest() {
786
+ const url = new TrackedURL('https://192.168.1.1:8080/path');
787
+
788
+ <button onClick={() => url.hostname = '10.0.0.1'}>{'Change IP'}</button>
789
+ <pre>{url.href}</pre>
790
+ <pre>{url.hostname}</pre>
791
+ }
792
+
793
+ render(URLTest);
794
+
795
+ const button = container.querySelector('button');
796
+
797
+ // Initial state
798
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://192.168.1.1:8080/path');
799
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('192.168.1.1');
800
+
801
+ // Change IP
802
+ button.click();
803
+ flushSync();
804
+
805
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('https://10.0.0.1:8080/path');
806
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('10.0.0.1');
807
+ });
808
+
809
+ it('handles href change updates all properties and searchParams', () => {
810
+ component URLTest() {
811
+ const url = new TrackedURL('https://old.com/old?foo=bar#old');
812
+ const params = url.searchParams;
813
+
814
+ <button onClick={() => url.href = 'https://new.com:9090/new?baz=qux#new'}>{'Change Href'}</button>
815
+ <pre>{params.get('foo')}</pre>
816
+ <pre>{params.get('baz')}</pre>
817
+ <pre>{params.size}</pre>
818
+ <pre>{url.pathname}</pre>
819
+ }
820
+
821
+ render(URLTest);
822
+
823
+ const button = container.querySelector('button');
824
+
825
+ // Initial state
826
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('bar');
827
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('');
828
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('1');
829
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('/old');
830
+
831
+ // Change href
832
+ button.click();
833
+ flushSync();
834
+
835
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('');
836
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('qux');
837
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('1');
838
+ expect(container.querySelectorAll('pre')[3].textContent).toBe('/new');
839
+ });
840
+
841
+ it('handles URL with localhost', () => {
842
+ component URLTest() {
843
+ const url = new TrackedURL('http://localhost:3000/api/data');
844
+
845
+ <button onClick={() => url.port = '8080'}>{'Change Port'}</button>
846
+ <pre>{url.href}</pre>
847
+ <pre>{url.hostname}</pre>
848
+ <pre>{url.port}</pre>
849
+ }
850
+
851
+ render(URLTest);
852
+
853
+ const button = container.querySelector('button');
854
+
855
+ // Initial state
856
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('http://localhost:3000/api/data');
857
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('localhost');
858
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('3000');
859
+
860
+ // Change port
861
+ button.click();
862
+ flushSync();
863
+
864
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('http://localhost:8080/api/data');
865
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('localhost');
866
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('8080');
867
+ });
868
+
869
+ it('handles URL with multiple path segments', () => {
870
+ component URLTest() {
871
+ const url = new TrackedURL('https://example.com/api/v1/users/123/profile');
872
+
873
+ <button onClick={() => url.pathname = '/api/v2/users/456/settings'}>{'Change Path'}</button>
874
+ <pre>{url.pathname}</pre>
875
+ <pre>{url.href}</pre>
876
+ }
877
+
878
+ render(URLTest);
879
+
880
+ const button = container.querySelector('button');
881
+
882
+ // Initial state
883
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('/api/v1/users/123/profile');
884
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https://example.com/api/v1/users/123/profile');
885
+
886
+ // Change path
887
+ button.click();
888
+ flushSync();
889
+
890
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('/api/v2/users/456/settings');
891
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https://example.com/api/v2/users/456/settings');
892
+ });
893
+
894
+ it('handles URL with file protocol', () => {
895
+ component URLTest() {
896
+ const url = new TrackedURL('file:///Users/username/documents/file.txt');
897
+
898
+ <pre>{url.protocol}</pre>
899
+ <pre>{url.pathname}</pre>
900
+ <pre>{url.href}</pre>
901
+ }
902
+
903
+ render(URLTest);
904
+
905
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('file:');
906
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('/Users/username/documents/file.txt');
907
+ expect(container.querySelectorAll('pre')[2].textContent).toBe('file:///Users/username/documents/file.txt');
908
+ });
909
+
910
+ it('handles hash without leading # character', () => {
911
+ component URLTest() {
912
+ const url = new TrackedURL('https://example.com/path');
913
+
914
+ <button onClick={() => url.hash = 'section'}>{'Set Hash'}</button>
915
+ <pre>{url.hash}</pre>
916
+ <pre>{url.href}</pre>
917
+ }
918
+
919
+ render(URLTest);
920
+
921
+ const button = container.querySelector('button');
922
+
923
+ // Initial state
924
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('');
925
+
926
+ // Set hash
927
+ button.click();
928
+ flushSync();
929
+
930
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('#section');
931
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https://example.com/path#section');
932
+ });
933
+
934
+ it('handles search without leading ? character', () => {
935
+ component URLTest() {
936
+ const url = new TrackedURL('https://example.com/path');
937
+
938
+ <button onClick={() => url.search = 'foo=bar'}>{'Set Search'}</button>
939
+ <pre>{url.search}</pre>
940
+ <pre>{url.href}</pre>
941
+ }
942
+
943
+ render(URLTest);
944
+
945
+ const button = container.querySelector('button');
946
+
947
+ // Set search
948
+ button.click();
949
+ flushSync();
950
+
951
+ expect(container.querySelectorAll('pre')[0].textContent).toBe('foo=bar');
952
+ expect(container.querySelectorAll('pre')[1].textContent).toBe('https://example.com/path?foo=bar');
953
+ });
954
+ });