a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import { registerPlugin } from "$core/plugin";
3import { signal } from "$core/signal";
4import { urlPlugin } from "$plugins/url";
5import { beforeEach, describe, expect, it, vi } from "vitest";
6
7describe("url plugin", () => {
8 beforeEach(() => {
9 registerPlugin("url", urlPlugin);
10 globalThis.history.replaceState({}, "", "/");
11 });
12
13 describe("read mode", () => {
14 it("reads URL parameter into signal on mount", () => {
15 globalThis.history.replaceState({}, "", "/?tab=profile");
16
17 const element = document.createElement("div");
18 element.dataset.voltUrl = "read:tab";
19
20 const tab = signal("");
21 mount(element, { tab });
22
23 expect(tab.get()).toBe("profile");
24 });
25
26 it("does not update URL when signal changes", async () => {
27 globalThis.history.replaceState({}, "", "/?tab=home");
28
29 const element = document.createElement("div");
30 element.dataset.voltUrl = "read:tab";
31
32 const tab = signal("");
33 mount(element, { tab });
34
35 tab.set("settings");
36
37 await new Promise((resolve) => setTimeout(resolve, 200));
38
39 expect(globalThis.location.search).toBe("?tab=home");
40 });
41
42 it("handles missing URL parameter", () => {
43 globalThis.history.replaceState({}, "", "/");
44
45 const element = document.createElement("div");
46 element.dataset.voltUrl = "read:missing";
47
48 const missing = signal("default");
49 mount(element, { missing });
50
51 expect(missing.get()).toBe("default");
52 });
53
54 it("deserializes boolean values", () => {
55 globalThis.history.replaceState({}, "", "/?active=true");
56
57 const element = document.createElement("div");
58 element.dataset.voltUrl = "read:active";
59
60 const active = signal(false);
61 mount(element, { active });
62
63 expect(active.get()).toBe(true);
64 });
65
66 it("deserializes number values", () => {
67 globalThis.history.replaceState({}, "", "/?count=42");
68
69 const element = document.createElement("div");
70 element.dataset.voltUrl = "read:count";
71
72 const count = signal(0);
73 mount(element, { count });
74
75 expect(count.get()).toBe(42);
76 });
77 });
78
79 describe("sync mode", () => {
80 it("reads URL parameter into signal on mount", () => {
81 globalThis.history.replaceState({}, "", "/?filter=active");
82
83 const element = document.createElement("div");
84 element.dataset.voltUrl = "sync:filter";
85
86 const filter = signal("");
87 mount(element, { filter });
88
89 expect(filter.get()).toBe("active");
90 });
91
92 it("supports attribute suffix syntax with query alias", async () => {
93 globalThis.history.replaceState({}, "", "/");
94
95 const element = document.createElement("div");
96 element.dataset["voltUrl:searchterm"] = "query";
97
98 const searchTerm = signal("");
99 mount(element, { searchTerm });
100
101 searchTerm.set("hello");
102
103 await new Promise((resolve) => setTimeout(resolve, 150));
104
105 expect(globalThis.location.search).toBe("?searchTerm=hello");
106 });
107
108 it("updates URL when signal changes", async () => {
109 globalThis.history.replaceState({}, "", "/");
110
111 const element = document.createElement("div");
112 element.dataset.voltUrl = "sync:query";
113
114 const query = signal("");
115 mount(element, { query });
116
117 query.set("search term");
118
119 await new Promise((resolve) => setTimeout(resolve, 150));
120
121 expect(globalThis.location.search).toContain("query=search+term");
122 });
123
124 it("removes parameter from URL when signal is empty", async () => {
125 globalThis.history.replaceState({}, "", "/?query=test");
126
127 const element = document.createElement("div");
128 element.dataset.voltUrl = "sync:query";
129
130 const query = signal("");
131 mount(element, { query });
132
133 query.set("");
134
135 await new Promise((resolve) => setTimeout(resolve, 150));
136
137 expect(globalThis.location.search).toBe("");
138 });
139
140 it("handles popstate events from browser navigation", () => {
141 globalThis.history.replaceState({}, "", "/?filter=all");
142
143 const element = document.createElement("div");
144 element.dataset.voltUrl = "sync:filter";
145
146 const filter = signal("");
147 mount(element, { filter });
148
149 expect(filter.get()).toBe("all");
150
151 globalThis.history.replaceState({}, "", "/?filter=completed");
152 globalThis.dispatchEvent(new PopStateEvent("popstate"));
153
154 expect(filter.get()).toBe("completed");
155 });
156
157 it("sets signal to empty string when parameter removed from URL", () => {
158 globalThis.history.replaceState({}, "", "/?filter=test");
159
160 const element = document.createElement("div");
161 element.dataset.voltUrl = "sync:filter";
162
163 const filter = signal("");
164 mount(element, { filter });
165
166 expect(filter.get()).toBe("test");
167
168 globalThis.history.replaceState({}, "", "/");
169 globalThis.dispatchEvent(new PopStateEvent("popstate"));
170
171 expect(filter.get()).toBe("");
172 });
173
174 it("debounces URL updates", async () => {
175 const pushStateSpy = vi.spyOn(globalThis.history, "pushState");
176
177 const element = document.createElement("div");
178 element.dataset.voltUrl = "sync:query";
179
180 const query = signal("");
181 mount(element, { query });
182
183 query.set("a");
184 query.set("ab");
185 query.set("abc");
186
187 await new Promise((resolve) => setTimeout(resolve, 50));
188 expect(pushStateSpy).not.toHaveBeenCalled();
189
190 await new Promise((resolve) => setTimeout(resolve, 100));
191 expect(pushStateSpy).toHaveBeenCalledOnce();
192
193 pushStateSpy.mockRestore();
194 });
195
196 it("cleans up popstate listener on unmount", () => {
197 globalThis.history.replaceState({}, "", "/?filter=test");
198
199 const element = document.createElement("div");
200 element.dataset.voltUrl = "sync:filter";
201
202 const filter = signal("");
203 const cleanup = mount(element, { filter });
204
205 expect(filter.get()).toBe("test");
206
207 cleanup();
208
209 globalThis.history.replaceState({}, "", "/?filter=other");
210 globalThis.dispatchEvent(new PopStateEvent("popstate"));
211
212 expect(filter.get()).toBe("test");
213 });
214 });
215
216 describe("hash mode", () => {
217 it("reads hash into signal on mount", () => {
218 globalThis.location.hash = "#/about";
219
220 const element = document.createElement("div");
221 element.dataset.voltUrl = "hash:route";
222
223 const route = signal("");
224 mount(element, { route });
225
226 expect(route.get()).toBe("/about");
227 });
228
229 it("updates hash when signal changes", () => {
230 globalThis.location.hash = "";
231
232 const element = document.createElement("div");
233 element.dataset.voltUrl = "hash:route";
234
235 const route = signal("");
236 mount(element, { route });
237
238 route.set("/contact");
239
240 expect(globalThis.location.hash).toBe("#/contact");
241 });
242
243 it("clears hash when signal is empty", () => {
244 globalThis.location.hash = "#/page";
245
246 const element = document.createElement("div");
247 element.dataset.voltUrl = "hash:route";
248
249 const route = signal("");
250 mount(element, { route });
251
252 route.set("");
253
254 expect(globalThis.location.hash).toBe("");
255 });
256
257 it("handles hashchange events", () => {
258 globalThis.location.hash = "#/home";
259
260 const element = document.createElement("div");
261 element.dataset.voltUrl = "hash:route";
262
263 const route = signal("");
264 mount(element, { route });
265
266 expect(route.get()).toBe("/home");
267
268 globalThis.location.hash = "#/settings";
269 globalThis.dispatchEvent(new Event("hashchange"));
270
271 expect(route.get()).toBe("/settings");
272 });
273
274 it("cleans up hashchange listener on unmount", () => {
275 globalThis.location.hash = "#/page1";
276
277 const element = document.createElement("div");
278 element.dataset.voltUrl = "hash:route";
279
280 const route = signal("");
281 const cleanup = mount(element, { route });
282
283 expect(route.get()).toBe("/page1");
284
285 cleanup();
286
287 globalThis.location.hash = "#/page2";
288 globalThis.dispatchEvent(new Event("hashchange"));
289
290 expect(route.get()).toBe("/page1");
291 });
292 });
293
294 describe("history mode", () => {
295 it("reads current pathname and search into signal on mount", () => {
296 globalThis.history.replaceState({}, "", "/products?category=electronics");
297
298 const element = document.createElement("div");
299 element.dataset.voltUrl = "history:route";
300
301 const route = signal("");
302 mount(element, { route });
303
304 expect(route.get()).toBe("/products?category=electronics");
305 });
306
307 it("initializes to root path when on root", () => {
308 globalThis.history.replaceState({}, "", "/");
309
310 const element = document.createElement("div");
311 element.dataset.voltUrl = "history:route";
312
313 const route = signal("");
314 mount(element, { route });
315
316 expect(route.get()).toBe("/");
317 });
318
319 it("updates URL when signal changes", () => {
320 globalThis.history.replaceState({}, "", "/");
321
322 const element = document.createElement("div");
323 element.dataset.voltUrl = "history:route";
324
325 const route = signal("");
326 mount(element, { route });
327
328 route.set("/dashboard");
329
330 expect(globalThis.location.pathname).toBe("/dashboard");
331 });
332
333 it("preserves search params when updating path", () => {
334 globalThis.history.replaceState({}, "", "/");
335
336 const element = document.createElement("div");
337 element.dataset.voltUrl = "history:route";
338
339 const route = signal("");
340 mount(element, { route });
341
342 route.set("/search?q=test&page=2");
343
344 expect(globalThis.location.pathname).toBe("/search");
345 expect(globalThis.location.search).toBe("?q=test&page=2");
346 });
347
348 it("handles base path configuration", () => {
349 globalThis.history.replaceState({}, "", "/app/dashboard");
350
351 const element = document.createElement("div");
352 element.dataset.voltUrl = "history:route:/app";
353
354 const route = signal("");
355 mount(element, { route });
356
357 expect(route.get()).toBe("/dashboard");
358 });
359
360 it("prepends base path when updating URL", () => {
361 globalThis.history.replaceState({}, "", "/app");
362
363 const element = document.createElement("div");
364 element.dataset.voltUrl = "history:route:/app";
365
366 const route = signal("");
367 mount(element, { route });
368
369 route.set("/settings");
370
371 expect(globalThis.location.pathname).toBe("/app/settings");
372 });
373
374 it("handles base path with trailing slash", () => {
375 globalThis.history.replaceState({}, "", "/myapp/profile");
376
377 const element = document.createElement("div");
378 element.dataset.voltUrl = "history:route:/myapp";
379
380 const route = signal("");
381 mount(element, { route });
382
383 expect(route.get()).toBe("/profile");
384 });
385
386 it("returns root when on base path", () => {
387 globalThis.history.replaceState({}, "", "/app");
388
389 const element = document.createElement("div");
390 element.dataset.voltUrl = "history:route:/app";
391
392 const route = signal("");
393 mount(element, { route });
394
395 expect(route.get()).toBe("/");
396 });
397
398 it("dispatches volt:navigate event when signal changes", () => {
399 const navigateHandler = vi.fn();
400 globalThis.addEventListener("volt:navigate", navigateHandler);
401
402 const element = document.createElement("div");
403 element.dataset.voltUrl = "history:route";
404
405 const route = signal("/");
406 mount(element, { route });
407
408 route.set("/about");
409
410 expect(navigateHandler).toHaveBeenCalled();
411 const event = navigateHandler.mock.calls[0][0] as CustomEvent;
412 expect(event.detail.url).toBe("/about");
413 expect(event.detail.route).toBe("/about");
414
415 globalThis.removeEventListener("volt:navigate", navigateHandler);
416 });
417
418 it("handles popstate events from browser navigation", () => {
419 globalThis.history.replaceState({}, "", "/page1");
420
421 const element = document.createElement("div");
422 element.dataset.voltUrl = "history:route";
423
424 const route = signal("");
425 mount(element, { route });
426
427 expect(route.get()).toBe("/page1");
428
429 globalThis.history.replaceState({}, "", "/page2");
430 globalThis.dispatchEvent(new PopStateEvent("popstate"));
431
432 expect(route.get()).toBe("/page2");
433 });
434
435 it("dispatches volt:popstate event on back/forward navigation", () => {
436 const popstateHandler = vi.fn();
437 globalThis.addEventListener("volt:popstate", popstateHandler);
438
439 const element = document.createElement("div");
440 element.dataset.voltUrl = "history:route";
441
442 const route = signal("/");
443 mount(element, { route });
444
445 globalThis.history.replaceState({}, "", "/other");
446 globalThis.dispatchEvent(new PopStateEvent("popstate"));
447
448 expect(popstateHandler).toHaveBeenCalled();
449 const event = popstateHandler.mock.calls[0][0] as CustomEvent;
450 expect(event.detail.route).toBe("/other");
451
452 globalThis.removeEventListener("volt:popstate", popstateHandler);
453 });
454
455 it("syncs with volt:navigate events from navigate plugin", () => {
456 const element = document.createElement("div");
457 element.dataset.voltUrl = "history:route";
458
459 const route = signal("/");
460 mount(element, { route });
461
462 globalThis.history.pushState({}, "", "/external-nav");
463 globalThis.dispatchEvent(new CustomEvent("volt:navigate", { detail: { url: "/external-nav" } }));
464 expect(route.get()).toBe("/external-nav");
465 });
466
467 it("does not update URL when already at target route", () => {
468 globalThis.history.replaceState({}, "", "/current");
469
470 const pushStateSpy = vi.spyOn(globalThis.history, "pushState");
471
472 const element = document.createElement("div");
473 element.dataset.voltUrl = "history:route";
474
475 const route = signal("");
476 mount(element, { route });
477
478 pushStateSpy.mockClear();
479
480 route.set("/current");
481
482 expect(pushStateSpy).not.toHaveBeenCalled();
483
484 pushStateSpy.mockRestore();
485 });
486
487 it("prevents infinite loops between signal and URL updates", () => {
488 const element = document.createElement("div");
489 element.dataset.voltUrl = "history:route";
490
491 const route = signal("/");
492 const subscribeSpy = vi.fn();
493 route.subscribe(subscribeSpy);
494
495 mount(element, { route });
496
497 subscribeSpy.mockClear();
498
499 route.set("/test");
500
501 globalThis.dispatchEvent(new PopStateEvent("popstate"));
502
503 expect(subscribeSpy).toHaveBeenCalledTimes(1);
504 });
505
506 it("cleans up listeners on unmount", () => {
507 globalThis.history.replaceState({}, "", "/initial");
508
509 const element = document.createElement("div");
510 element.dataset.voltUrl = "history:route";
511
512 const route = signal("");
513 const cleanup = mount(element, { route });
514
515 expect(route.get()).toBe("/initial");
516
517 cleanup();
518
519 globalThis.history.replaceState({}, "", "/changed");
520 globalThis.dispatchEvent(new PopStateEvent("popstate"));
521
522 expect(route.get()).toBe("/initial");
523 });
524
525 it("handles complex routes with multiple path segments", () => {
526 globalThis.history.replaceState({}, "", "/blog/2024/introducing-volt");
527
528 const element = document.createElement("div");
529 element.dataset.voltUrl = "history:route";
530
531 const route = signal("");
532 mount(element, { route });
533
534 expect(route.get()).toBe("/blog/2024/introducing-volt");
535 });
536
537 it("handles routes with query parameters", () => {
538 globalThis.history.replaceState({}, "", "/search?q=reactive&lang=ts");
539
540 const element = document.createElement("div");
541 element.dataset.voltUrl = "history:route";
542
543 const route = signal("");
544 mount(element, { route });
545
546 expect(route.get()).toBe("/search?q=reactive&lang=ts");
547 });
548
549 it("logs error when signal not found", () => {
550 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
551
552 const element = document.createElement("div");
553 element.dataset.voltUrl = "history:nonexistent";
554
555 mount(element, {});
556
557 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found"));
558
559 errorSpy.mockRestore();
560 });
561 });
562
563 describe("error handling", () => {
564 it("logs error for invalid binding format", () => {
565 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
566 const element = document.createElement("div");
567 element.dataset.voltUrl = "invalidformat";
568
569 mount(element, {});
570
571 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid url binding"));
572
573 errorSpy.mockRestore();
574 });
575
576 it("logs error for unknown url mode", () => {
577 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
578 const element = document.createElement("div");
579 element.dataset.voltUrl = "unknown:signal";
580
581 mount(element, {});
582
583 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown url mode"));
584
585 errorSpy.mockRestore();
586 });
587
588 it("logs error when signal not found", () => {
589 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
590 const element = document.createElement("div");
591 element.dataset.voltUrl = "read:nonexistent";
592
593 mount(element, {});
594
595 expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Signal \"nonexistent\" not found"));
596
597 errorSpy.mockRestore();
598 });
599 });
600});