a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { mount } from "$core/binder";
2import { goBack, goForward, initNavigationListener, navigate, redirect } from "$plugins/navigate";
3import { beforeEach, describe, expect, it, vi } from "vitest";
4
5describe("navigate plugin", () => {
6 beforeEach(() => {
7 globalThis.history.replaceState({}, "", "/");
8 vi.clearAllMocks();
9 });
10
11 describe("link navigation", () => {
12 it("intercepts link clicks and prevents default navigation", () => {
13 const link = document.createElement("a");
14 link.href = "/about";
15 link.dataset.voltNavigate = "";
16
17 const preventDefault = vi.fn();
18 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
19 Object.defineProperty(event, "preventDefault", { value: preventDefault });
20
21 mount(link, {});
22 link.dispatchEvent(event);
23
24 expect(preventDefault).toHaveBeenCalled();
25 });
26
27 it("navigates to href when no explicit URL provided", () => {
28 const link = document.createElement("a");
29 link.href = "/products";
30 link.dataset.voltNavigate = "";
31
32 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
33 mount(link, {});
34 link.dispatchEvent(event);
35
36 expect(globalThis.location.pathname).toBe("/products");
37 });
38
39 it("navigates to explicit URL when provided", () => {
40 const link = document.createElement("a");
41 link.href = "/default";
42 link.dataset.voltNavigate = "/custom";
43
44 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
45 mount(link, {});
46 link.dispatchEvent(event);
47
48 expect(globalThis.location.pathname).toBe("/custom");
49 });
50
51 it("allows ctrl+click to open in new tab (does not prevent default)", () => {
52 const link = document.createElement("a");
53 link.href = "/external";
54 link.dataset.voltNavigate = "";
55
56 const preventDefault = vi.fn();
57 const event = new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true });
58 Object.defineProperty(event, "preventDefault", { value: preventDefault });
59
60 mount(link, {});
61 link.dispatchEvent(event);
62
63 expect(preventDefault).not.toHaveBeenCalled();
64 });
65
66 it("allows meta+click to open in new tab (does not prevent default)", () => {
67 const link = document.createElement("a");
68 link.href = "/external";
69 link.dataset.voltNavigate = "";
70
71 const preventDefault = vi.fn();
72 const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true });
73 Object.defineProperty(event, "preventDefault", { value: preventDefault });
74
75 mount(link, {});
76 link.dispatchEvent(event);
77
78 expect(preventDefault).not.toHaveBeenCalled();
79 });
80
81 it("allows shift+click to open in new window (does not prevent default)", () => {
82 const link = document.createElement("a");
83 link.href = "/external";
84 link.dataset.voltNavigate = "";
85
86 const preventDefault = vi.fn();
87 const event = new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true });
88 Object.defineProperty(event, "preventDefault", { value: preventDefault });
89
90 mount(link, {});
91 link.dispatchEvent(event);
92
93 expect(preventDefault).not.toHaveBeenCalled();
94 });
95
96 it("allows middle mouse button to open in new tab (does not prevent default)", () => {
97 const link = document.createElement("a");
98 link.href = "/external";
99 link.dataset.voltNavigate = "";
100
101 const preventDefault = vi.fn();
102 const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 });
103 Object.defineProperty(event, "preventDefault", { value: preventDefault });
104
105 mount(link, {});
106 link.dispatchEvent(event);
107
108 expect(preventDefault).not.toHaveBeenCalled();
109 });
110
111 it("does not intercept external links", () => {
112 const link = document.createElement("a");
113 link.href = "https://external.com/page";
114 link.dataset.voltNavigate = "";
115
116 const preventDefault = vi.fn();
117 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
118 Object.defineProperty(event, "preventDefault", { value: preventDefault });
119
120 mount(link, {});
121 link.dispatchEvent(event);
122
123 expect(preventDefault).not.toHaveBeenCalled();
124 });
125
126 it("uses replaceState when .replace modifier is used", () => {
127 globalThis.history.replaceState({}, "", "/initial");
128
129 const link = document.createElement("a");
130 link.href = "/about";
131 link.dataset.voltNavigateReplace = "";
132
133 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
134 mount(link, {});
135 link.dispatchEvent(event);
136
137 expect(globalThis.location.pathname).toBe("/about");
138 });
139
140 it("scrolls to top on navigation", async () => {
141 const scrollToSpy = vi.spyOn(globalThis, "scrollTo");
142
143 const link = document.createElement("a");
144 link.href = "/page";
145 link.dataset.voltNavigate = "";
146
147 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
148 mount(link, {});
149 link.dispatchEvent(event);
150
151 await vi.waitFor(() => {
152 expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
153 });
154 });
155
156 it("dispatches volt:navigate event on navigation", async () => {
157 const navigateHandler = vi.fn();
158 globalThis.addEventListener("volt:navigate", navigateHandler);
159
160 const link = document.createElement("a");
161 link.href = "/dashboard";
162 link.dataset.voltNavigate = "";
163
164 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
165 mount(link, {});
166 link.dispatchEvent(event);
167
168 await vi.waitFor(() => {
169 expect(navigateHandler).toHaveBeenCalled();
170 const customEvent = navigateHandler.mock.calls[0][0] as CustomEvent;
171 expect(customEvent.detail.url).toBe("/dashboard");
172 });
173
174 globalThis.removeEventListener("volt:navigate", navigateHandler);
175 });
176
177 it("adds prefetch link on hover when .prefetch modifier is used", async () => {
178 const link = document.createElement("a");
179 link.href = "/prefetch-page";
180 link.dataset.voltNavigatePrefetch = "";
181
182 mount(link, {});
183
184 const mouseenterEvent = new MouseEvent("mouseenter", { bubbles: true });
185 link.dispatchEvent(mouseenterEvent);
186
187 await vi.waitFor(() => {
188 const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-page\"]");
189 expect(prefetchLink).toBeTruthy();
190 });
191 });
192
193 it("adds prefetch link on focus when .prefetch modifier is used", async () => {
194 const link = document.createElement("a");
195 link.href = "/prefetch-focus";
196 link.dataset.voltNavigatePrefetch = "";
197
198 mount(link, {});
199
200 const focusEvent = new FocusEvent("focus", { bubbles: true });
201 link.dispatchEvent(focusEvent);
202
203 await vi.waitFor(() => {
204 const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/prefetch-focus\"]");
205 expect(prefetchLink).toBeTruthy();
206 });
207 });
208
209 it("only prefetches once even with multiple hover events", async () => {
210 const link = document.createElement("a");
211 link.href = "/prefetch-once";
212 link.dataset.voltNavigatePrefetch = "";
213
214 mount(link, {});
215
216 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
217 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
218 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
219
220 await vi.waitFor(() => {
221 const prefetchLinks = document.querySelectorAll("link[rel=\"prefetch\"][href=\"/prefetch-once\"]");
222 expect(prefetchLinks.length).toBe(1);
223 });
224 });
225 });
226
227 describe("form navigation", () => {
228 it("intercepts form GET submissions", () => {
229 const form = document.createElement("form");
230 form.method = "GET";
231 form.action = "/search";
232 form.dataset.voltNavigate = "";
233
234 const input = document.createElement("input");
235 input.name = "q";
236 input.value = "test";
237 form.append(input);
238
239 const preventDefault = vi.fn();
240 const event = new Event("submit", { bubbles: true, cancelable: true });
241 Object.defineProperty(event, "preventDefault", { value: preventDefault });
242
243 mount(form, {});
244 form.dispatchEvent(event);
245
246 expect(preventDefault).toHaveBeenCalled();
247 expect(globalThis.location.pathname).toBe("/search");
248 expect(globalThis.location.search).toContain("q=test");
249 });
250
251 it("warns on POST form submissions", () => {
252 const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
253
254 const form = document.createElement("form");
255 form.method = "POST";
256 form.action = "/submit";
257 form.dataset.voltNavigate = "";
258
259 const event = new Event("submit", { bubbles: true, cancelable: true });
260 mount(form, {});
261 form.dispatchEvent(event);
262
263 expect(consoleWarnSpy).toHaveBeenCalledWith(
264 expect.stringContaining("POST/PUT/PATCH forms should use data-volt-post/put/patch"),
265 );
266
267 consoleWarnSpy.mockRestore();
268 });
269
270 it("uses current pathname as default action", () => {
271 globalThis.history.replaceState({}, "", "/current");
272
273 const form = document.createElement("form");
274 form.method = "GET";
275 form.dataset.voltNavigate = "";
276
277 const input = document.createElement("input");
278 input.name = "filter";
279 input.value = "active";
280 form.append(input);
281
282 const event = new Event("submit", { bubbles: true, cancelable: true });
283 mount(form, {});
284 form.dispatchEvent(event);
285
286 expect(globalThis.location.pathname).toBe("/current");
287 expect(globalThis.location.search).toContain("filter=active");
288 });
289 });
290
291 describe("programmatic navigation", () => {
292 it("navigate() changes the URL", async () => {
293 await navigate("/dashboard");
294 expect(globalThis.location.pathname).toBe("/dashboard");
295 });
296
297 it("navigate() with replace option uses replaceState", async () => {
298 globalThis.history.replaceState({}, "", "/initial");
299 await navigate("/replaced", { replace: true });
300 expect(globalThis.location.pathname).toBe("/replaced");
301 });
302
303 it("redirect() uses replaceState", async () => {
304 globalThis.history.replaceState({}, "", "/old");
305 await redirect("/new");
306 expect(globalThis.location.pathname).toBe("/new");
307 });
308
309 it("navigate() dispatches volt:navigate event", async () => {
310 const handler = vi.fn();
311 globalThis.addEventListener("volt:navigate", handler);
312
313 await navigate("/profile");
314
315 expect(handler).toHaveBeenCalled();
316 const event = handler.mock.calls[0][0] as CustomEvent;
317 expect(event.detail.url).toBe("/profile");
318
319 globalThis.removeEventListener("volt:navigate", handler);
320 });
321
322 it("goBack() navigates backward in history", () => {
323 const backSpy = vi.spyOn(globalThis.history, "back");
324 goBack();
325 expect(backSpy).toHaveBeenCalled();
326 });
327
328 it("goForward() navigates forward in history", () => {
329 const forwardSpy = vi.spyOn(globalThis.history, "forward");
330 goForward();
331 expect(forwardSpy).toHaveBeenCalled();
332 });
333 });
334
335 describe("scroll position restoration", () => {
336 it("saves scroll position before navigation", async () => {
337 Object.defineProperty(globalThis, "scrollX", { value: 100, writable: true, configurable: true });
338 Object.defineProperty(globalThis, "scrollY", { value: 200, writable: true, configurable: true });
339
340 await navigate("/page1");
341 await navigate("/page2");
342
343 expect(globalThis.history.state).toBeDefined();
344 });
345
346 it("restores scroll position on popstate", async () => {
347 const cleanup = initNavigationListener();
348
349 Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true });
350 Object.defineProperty(globalThis, "scrollY", { value: 0, writable: true, configurable: true });
351 await navigate("/page1");
352
353 Object.defineProperty(globalThis, "scrollX", { value: 0, writable: true, configurable: true });
354 Object.defineProperty(globalThis, "scrollY", { value: 500, writable: true, configurable: true });
355
356 await navigate("/page2");
357
358 const scrollToSpy = vi.spyOn(globalThis, "scrollTo");
359 globalThis.history.back();
360 globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { scrollPosition: { x: 0, y: 500 } } }));
361
362 await vi.waitFor(() => {
363 expect(scrollToSpy).toHaveBeenCalledWith(0, 500);
364 });
365
366 cleanup();
367 });
368
369 it("dispatches volt:popstate event on back/forward navigation", async () => {
370 const cleanup = initNavigationListener();
371 const popstateHandler = vi.fn();
372 globalThis.addEventListener("volt:popstate", popstateHandler);
373
374 globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { timestamp: Date.now() } }));
375
376 await vi.waitFor(() => {
377 expect(popstateHandler).toHaveBeenCalled();
378 });
379
380 globalThis.removeEventListener("volt:popstate", popstateHandler);
381 cleanup();
382 });
383 });
384
385 describe("navigation state", () => {
386 it("stores navigation state in history", async () => {
387 await navigate("/stateful");
388
389 expect(globalThis.history.state).toBeDefined();
390 expect(globalThis.history.state.timestamp).toBeDefined();
391 expect(typeof globalThis.history.state.timestamp).toBe("number");
392 });
393
394 it("includes scroll position in navigation state", async () => {
395 Object.defineProperty(globalThis, "scrollX", { value: 150, writable: true, configurable: true });
396 Object.defineProperty(globalThis, "scrollY", { value: 300, writable: true, configurable: true });
397
398 await navigate("/with-scroll");
399
400 expect(globalThis.history.state.scrollPosition).toBeDefined();
401 expect(globalThis.history.state.scrollPosition.x).toBe(150);
402 expect(globalThis.history.state.scrollPosition.y).toBe(300);
403 });
404 });
405
406 describe("view transitions", () => {
407 it("uses view transitions by default", async () => {
408 const mockTransition = {
409 finished: Promise.resolve(),
410 ready: Promise.resolve(),
411 updateCbDone: Promise.resolve(),
412 skipTransition: vi.fn(),
413 };
414
415 const startViewTransitionSpy = vi.fn(() => mockTransition);
416 Object.defineProperty(document, "startViewTransition", {
417 value: startViewTransitionSpy,
418 writable: true,
419 configurable: true,
420 });
421
422 await navigate("/with-transition");
423
424 expect(startViewTransitionSpy).toHaveBeenCalled();
425 });
426
427 it("skips view transitions when notransition modifier is used", async () => {
428 const link = document.createElement("a");
429 link.href = "/no-transition";
430 link.dataset.voltNavigateNotransition = "";
431
432 const mockTransition = {
433 finished: Promise.resolve(),
434 ready: Promise.resolve(),
435 updateCbDone: Promise.resolve(),
436 skipTransition: vi.fn(),
437 };
438
439 const startViewTransitionSpy = vi.fn(() => mockTransition);
440 Object.defineProperty(document, "startViewTransition", {
441 value: startViewTransitionSpy,
442 writable: true,
443 configurable: true,
444 });
445
446 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
447 mount(link, {});
448 link.dispatchEvent(event);
449
450 await vi.waitFor(() => {
451 expect(globalThis.location.pathname).toBe("/no-transition");
452 });
453 });
454
455 it("can disable view transitions programmatically", async () => {
456 await navigate("/no-vt", { transition: false });
457 expect(globalThis.location.pathname).toBe("/no-vt");
458 });
459 });
460
461 describe("focus management", () => {
462 it("includes focus restoration functions in navigate module", () => {
463 expect(navigate).toBeDefined();
464 expect(initNavigationListener).toBeDefined();
465 });
466
467 it.skip("saves focus state in navigation state when element has ID", async () => {
468 const input = document.createElement("input");
469 input.id = "test-input";
470 input.type = "text";
471 document.body.append(input);
472
473 const activeElementGetter = vi.spyOn(document, "activeElement", "get");
474 activeElementGetter.mockReturnValue(input);
475
476 await navigate("/page-with-focus");
477
478 expect(globalThis.history.state.focusSelector).toBe("#test-input");
479
480 activeElementGetter.mockRestore();
481 input.remove();
482 });
483
484 it.skip("attempts to restore focus on popstate", () => {
485 const cleanup = initNavigationListener();
486
487 const button = document.createElement("button");
488 button.id = "focus-button";
489 button.textContent = "Click me";
490 document.body.append(button);
491
492 const focusSpy = vi.spyOn(button, "focus");
493
494 globalThis.dispatchEvent(new PopStateEvent("popstate", { state: { focusSelector: "#focus-button" } }));
495
496 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
497
498 cleanup();
499 button.remove();
500 });
501 });
502
503 describe("viewport-based prefetching", () => {
504 it.skip("prefetches when link enters viewport with .viewport modifier", async () => {
505 const link = document.createElement("a");
506 link.href = "/viewport-prefetch";
507 link.dataset.voltNavigatePrefetchViewport = "";
508 document.body.append(link);
509
510 let observerCallback!: IntersectionObserverCallback;
511 const mockObserver = {
512 observe: vi.fn(),
513 disconnect: vi.fn(),
514 unobserve: vi.fn(),
515 takeRecords: vi.fn(),
516 root: null,
517 rootMargin: "",
518 thresholds: [],
519 };
520
521 (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => {
522 observerCallback = callback;
523 return mockObserver;
524 }) as unknown as typeof IntersectionObserver;
525
526 const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
527
528 mount(link, {});
529
530 expect(mockObserver.observe).toHaveBeenCalledWith(link);
531
532 observerCallback(
533 [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry],
534 mockObserver as IntersectionObserver,
535 );
536
537 await vi.waitFor(() => {
538 expect(fetchSpy).toHaveBeenCalledWith("/viewport-prefetch", expect.objectContaining({ method: "GET" }));
539 });
540
541 fetchSpy.mockRestore();
542 link.remove();
543 });
544
545 it.skip("only prefetches once when element enters viewport multiple times", async () => {
546 const link = document.createElement("a");
547 link.href = "/viewport-once";
548 link.dataset.voltNavigatePrefetchViewport = "";
549 document.body.append(link);
550
551 let observerCallback!: IntersectionObserverCallback;
552 const mockObserver = {
553 observe: vi.fn(),
554 disconnect: vi.fn(),
555 unobserve: vi.fn(),
556 takeRecords: vi.fn(),
557 root: null,
558 rootMargin: "",
559 thresholds: [],
560 };
561
562 (globalThis as typeof globalThis).IntersectionObserver = vi.fn((callback) => {
563 observerCallback = callback;
564 return mockObserver;
565 }) as unknown as typeof IntersectionObserver;
566
567 const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response());
568
569 mount(link, {});
570
571 observerCallback(
572 [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry],
573 mockObserver as IntersectionObserver,
574 );
575
576 observerCallback(
577 [{ isIntersecting: false, target: link } as unknown as IntersectionObserverEntry],
578 mockObserver as IntersectionObserver,
579 );
580
581 observerCallback(
582 [{ isIntersecting: true, target: link } as unknown as IntersectionObserverEntry],
583 mockObserver as IntersectionObserver,
584 );
585
586 await vi.waitFor(() => {
587 expect(fetchSpy).toHaveBeenCalledTimes(1);
588 expect(mockObserver.disconnect).toHaveBeenCalled();
589 });
590
591 fetchSpy.mockRestore();
592 link.remove();
593 });
594
595 it("falls back to link prefetch when fetch fails", async () => {
596 const link = document.createElement("a");
597 link.href = "/fetch-fail";
598 link.dataset.voltNavigatePrefetch = "";
599 document.body.append(link);
600
601 const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
602
603 mount(link, {});
604
605 link.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
606
607 await vi.waitFor(() => {
608 const prefetchLink = document.querySelector("link[rel=\"prefetch\"][href=\"/fetch-fail\"]");
609 expect(prefetchLink).toBeTruthy();
610 });
611
612 fetchSpy.mockRestore();
613 link.remove();
614 });
615 });
616
617 describe("cleanup", () => {
618 it("removes event listeners on cleanup", () => {
619 const link = document.createElement("a");
620 link.href = "/cleanup-test";
621 link.dataset.voltNavigate = "";
622
623 const cleanup = mount(link, {});
624 const preventDefault = vi.fn();
625 const event = new MouseEvent("click", { bubbles: true, cancelable: true });
626 Object.defineProperty(event, "preventDefault", { value: preventDefault });
627 link.dispatchEvent(event);
628 expect(preventDefault).toHaveBeenCalled();
629 cleanup();
630
631 preventDefault.mockClear();
632 const event2 = new MouseEvent("click", { bubbles: true, cancelable: true });
633 Object.defineProperty(event2, "preventDefault", { value: preventDefault });
634 link.dispatchEvent(event2);
635 expect(preventDefault).not.toHaveBeenCalled();
636 });
637
638 it("initNavigationListener returns cleanup function", () => {
639 const cleanup = initNavigationListener();
640 expect(typeof cleanup).toBe("function");
641
642 const popstateHandler = vi.fn();
643 globalThis.addEventListener("volt:popstate", popstateHandler);
644
645 globalThis.dispatchEvent(new PopStateEvent("popstate"));
646 expect(popstateHandler).toHaveBeenCalled();
647
648 cleanup();
649 popstateHandler.mockClear();
650
651 globalThis.dispatchEvent(new PopStateEvent("popstate"));
652 globalThis.removeEventListener("volt:popstate", popstateHandler);
653 });
654 });
655});