a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 18 kB view raw
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});