import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert/strict"; import { Router, Layout } from "/js/router.js"; class TestLayout extends Layout { constructor() { super(); this.slot = document.createElement("div"); this.mountedInto = null; } mount(container) { this.mountedInto = container; container.appendChild(this.slot); } } function mountRouter(router, { layout = null } = {}) { const root = document.createElement("div"); if (layout) { router.setLayout(layout); } router.mount(root); return { root, defaultContainer: router.containers.default, bareContainer: router.containers.bare, layoutContainer: router.containers.layout, }; } // JSDOM's window.scrollY is a read-only getter, so temporarily override it to // simulate the page being scrolled before we navigate away. function withScrollY(value, callback) { const original = Object.getOwnPropertyDescriptor(window, "scrollY"); Object.defineProperty(window, "scrollY", { value, configurable: true, }); return (async () => { try { return await callback(); } finally { if (original) { Object.defineProperty(window, "scrollY", original); } else { delete window.scrollY; } } })(); } describe("constructor and initialization", () => { it("should initialize with empty routes", () => { const router = new Router(); assert.deepEqual(Object.keys(router.routes).length, 0); }); it("should initialize with default notFoundView", () => { const router = new Router(); assert(typeof router.notFoundView === "function"); }); it("should initialize with null containers", () => { const router = new Router(); assert.deepEqual(router.containers, { default: null, bare: null, layout: null, }); }); it("should initialize with a null current route", () => { const router = new Router(); assert.deepEqual(router.$currentRoute.get(), null); }); }); describe("addRoute", () => { it("should add a route with viewGetter", () => { const router = new Router(); const viewGetter = () => "view"; router.addRoute("/test", viewGetter); assert(router.routes["/test"]); assert.deepEqual(router.routes["/test"].viewGetter, viewGetter); }); it("should add multiple routes", () => { const router = new Router(); router.addRoute("/path1", () => {}); router.addRoute("/path2", () => {}); assert.deepEqual(Object.keys(router.routes).length, 2); }); it("should register every path in an array under the same view and options", () => { const router = new Router(); const viewGetter = () => "view"; const options = { layoutOptions: { activeNavItem: "home" } }; router.addRoute(["/", "/intent/compose"], viewGetter, options); assert.deepEqual(Object.keys(router.routes), ["/", "/intent/compose"]); assert.deepEqual(router.match("/intent/compose").viewGetter, viewGetter); assert.deepEqual(router.match("/intent/compose").options, options); assert.deepEqual(router.match("/").viewGetter, viewGetter); }); }); describe("setNotFoundView", () => { it("should set notFoundView function", () => { const router = new Router(); const notFoundView = () => "404"; router.setNotFoundView(notFoundView); assert.deepEqual(router.notFoundView, notFoundView); }); }); describe("mount", () => { it("should default pages to the root and create the bare container", () => { const router = new Router(); const { root, defaultContainer, bareContainer } = mountRouter(router); assert.deepEqual(defaultContainer, root); assert.deepEqual(bareContainer, root.querySelector("#bare-pages")); }); it("should mount the layout into a router-created container and use its slot for pages", () => { const router = new Router(); const layout = new TestLayout(); const { root, defaultContainer, bareContainer, layoutContainer } = mountRouter(router, { layout }); assert.deepEqual(layout.mountedInto, layoutContainer); assert.deepEqual(layoutContainer.parentElement, root); assert(layoutContainer.contains(layout.slot)); assert.deepEqual(defaultContainer, layout.slot); assert.deepEqual(bareContainer, root.querySelector("#bare-pages")); }); it("should clear pre-existing root contents", () => { const router = new Router(); const root = document.createElement("div"); root.innerHTML = "

stale ssr/loading markup

"; router.mount(root); assert.deepEqual(root.querySelector("p"), null); }); it("should throw when the layout does not expose a slot element", () => { const router = new Router(); const slotlessLayout = new Layout(); router.setLayout(slotlessLayout); assert.throws( () => router.mount(document.createElement("div")), /slot element/, ); }); }); describe("matchPath (static method)", () => { it("should match exact path", () => { const params = Router.matchPath("/test", "/test"); assert.deepEqual(params, {}); }); it("should return null for non-matching paths", () => { const params = Router.matchPath("/test", "/other"); assert.deepEqual(params, null); }); it("should extract single parameter", () => { const params = Router.matchPath("/user/john", "/user/:name"); assert.deepEqual(params, { name: "john" }); }); it("should extract multiple parameters", () => { const params = Router.matchPath( "/profile/gracekind.net/post/3lykznxiikc2k", "/profile/:handle/post/:rkey", ); assert.deepEqual(params, { handle: "gracekind.net", rkey: "3lykznxiikc2k", }); }); it("should return null for different path lengths", () => { const params = Router.matchPath("/test/extra", "/test"); assert.deepEqual(params, null); }); it("should handle empty path segments", () => { const params = Router.matchPath("/", "/"); assert.deepEqual(params, {}); }); it("should match path with parameter at start", () => { const params = Router.matchPath("/john/profile", "/:name/profile"); assert.deepEqual(params, { name: "john" }); }); it("should decode percent-encoded path parameters", () => { const params = Router.matchPath("/hashtag/hello%20world", "/hashtag/:tag"); assert.deepEqual(params, { tag: "hello world" }); }); it("should decode encoded slashes in parameters", () => { const params = Router.matchPath( "/profile/alice%2Fevil/post/abc123", "/profile/:handle/post/:rkey", ); assert.deepEqual(params, { handle: "alice/evil", rkey: "abc123" }); }); it("should fall back to the raw segment for malformed percent-encoding", () => { const params = Router.matchPath("/profile/%zz", "/profile/:handle"); assert.deepEqual(params, { handle: "%zz" }); }); it("should preserve colons in DID parameters", () => { const params = Router.matchPath( "/profile/did:plc:abc123/post/key456", "/profile/:handle/post/:rkey", ); assert.deepEqual(params, { handle: "did:plc:abc123", rkey: "key456" }); }); }); describe("match", () => { it("should match existing route", () => { const router = new Router(); const viewGetter = () => "view"; router.addRoute("/test", viewGetter); const result = router.match("/test"); assert.deepEqual(result.route, "/test"); assert.deepEqual(result.viewGetter, viewGetter); assert.deepEqual(result.params, {}); }); it("should match route with parameters", () => { const router = new Router(); const viewGetter = () => "view"; router.addRoute("/user/:id", viewGetter); const result = router.match("/user/123"); assert.deepEqual(result.route, "/user/:id"); assert.deepEqual(result.params, { id: "123" }); }); it("should match with the raw segment for malformed percent-encoding", () => { const router = new Router(); router.addRoute("/profile/:handle", () => "view"); const result = router.match("/profile/%zz"); assert.deepEqual(result.route, "/profile/:handle"); assert.deepEqual(result.params, { handle: "%zz" }); }); it("should return notFoundView for non-matching path", () => { const router = new Router(); const notFoundView = () => "404"; router.setNotFoundView(notFoundView); const result = router.match("/nonexistent"); assert.deepEqual(result.route, null); assert.deepEqual(result.viewGetter, notFoundView); assert.deepEqual(result.params, {}); }); it("should match first matching route", () => { const router = new Router(); const view1 = () => "view1"; const view2 = () => "view2"; router.addRoute("/user/:id", view1); router.addRoute("/user/:name", view2); const result = router.match("/user/123"); assert.deepEqual(result.viewGetter, view1); }); }); describe("renderRoute", () => { it("should set renderFunc", () => { const router = new Router(); const renderFunc = () => {}; router.renderRoute(renderFunc); assert.deepEqual(router.renderFunc, renderFunc); }); }); describe("setErrorViewResolver", () => { function setupRouter() { const router = new Router(); mountRouter(router); router.renderRoute(({ view, params, container }) => view({ root: container, params }), ); return router; } it("renders the resolved error view in place of a view that throws", async () => { const router = setupRouter(); const failure = new Error("boom"); router.addRoute("/broken/:id", () => async ({ root }) => { root.appendChild(document.createElement("span")); throw failure; }); const errorView = mock.fn(async ({ root }) => { root.textContent = "error page"; }); const seenErrors = []; router.setErrorViewResolver((error) => { seenErrors.push(error); return () => errorView; }); await router.load("/broken/123"); assert.deepEqual(seenErrors, [failure]); assert.deepEqual(errorView.mock.callCount(), 1); assert.deepEqual(errorView.mock.calls[0].arguments[0].params, { id: "123", }); assert.deepEqual(router.currentPage.textContent, "error page"); assert.deepEqual(router.currentPage.querySelectorAll("span").length, 0); }); it("rethrows when no error view resolver is set", async () => { const router = setupRouter(); router.addRoute("/broken", () => async () => { throw new Error("boom"); }); await assert.rejects(() => router.load("/broken"), /boom/); }); it("rethrows when the resolver returns no view", async () => { const router = setupRouter(); router.addRoute("/broken", () => async () => { throw new Error("boom"); }); router.setErrorViewResolver(() => null); await assert.rejects(() => router.load("/broken"), /boom/); }); }); describe("popstate", () => { // Capture the Router's popstate handler rather than dispatching a global // popstate event, since previously-created Router instances in other tests // also have popstate listeners on window and would fire here. function createRouterWithPopstateHandler() { const origAdd = window.addEventListener.bind(window); let popstateHandler = null; window.addEventListener = (event, handler, options) => { if (event === "popstate" && popstateHandler === null) { popstateHandler = handler; } else { origAdd(event, handler, options); } }; const router = new Router(); window.addEventListener = origAdd; return { router, popstateHandler }; } it("should emit navigate event when popstate fires", async () => { const { router, popstateHandler } = createRouterWithPopstateHandler(); mountRouter(router); const listener = mock.fn(); router.on("navigate", listener); await popstateHandler(new Event("popstate")); assert.deepEqual(listener.mock.callCount(), 1); }); it("should emit navigate before loading the new page", async () => { const { router, popstateHandler } = createRouterWithPopstateHandler(); mountRouter(router); const order = []; router.on("navigate", () => order.push("navigate")); router.renderRoute(() => order.push("render")); await popstateHandler(new Event("popstate")); assert.deepEqual(order[0], "navigate"); }); it("restores a query-bearing page from cache on back navigation", async () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; const { router, popstateHandler } = createRouterWithPopstateHandler(); mountRouter(router); router.addRoute("/search", () => Promise.resolve({})); router.addRoute("/other", () => Promise.resolve({})); router.renderRoute(() => {}); try { await router.load("/search?q=alice"); const searchPage = router.pages.get("/search?q=alice")?.el; assert(searchPage, "page should be cached under its full path"); await router.load("/other"); // Simulate the back button landing on the query-bearing URL. window.history.replaceState({}, "", "/search?q=alice"); await popstateHandler(new Event("popstate")); // The cached page is reused rather than rebuilt under the query-less path. assert( router.currentPage === searchPage, "should reuse the cached query-bearing page", ); assert( !router.pages.has("/search"), "should not create a query-less duplicate page", ); } finally { window.history.replaceState(originalState, "", originalPath); } }); }); describe("load", () => { // Reached without go()'s same-path guard when popstate lands on the URL // the app is already showing. it("keeps the page visible when re-loading the current path", async () => { const router = new Router(); mountRouter(router); router.addRoute("/load-same-test", () => Promise.resolve({})); router.renderRoute(() => {}); await router.load("/load-same-test"); const page = router.currentPage; await router.load("/load-same-test"); assert.deepEqual(router.currentPage, page); assert(page.classList.contains("page-visible")); assert(!page.classList.contains("page-hidden")); }); it("should load route and render view", async () => { const router = new Router(); const { defaultContainer } = mountRouter(router); const view = { name: "TestView" }; const viewGetter = () => Promise.resolve(view); router.addRoute("/test", viewGetter); let renderCalled = false; let renderArgs = null; router.renderRoute((args) => { renderCalled = true; renderArgs = args; }); await router.load("/test"); assert(renderCalled); assert.deepEqual(renderArgs.view, view); assert.deepEqual(renderArgs.params, {}); assert(renderArgs.container); assert(defaultContainer.contains(renderArgs.container)); }); it("should pass route parameters to renderFunc", async () => { const router = new Router(); mountRouter(router); router.addRoute("/user/:id", () => Promise.resolve({})); let receivedParams = null; router.renderRoute((args) => { receivedParams = args.params; }); await router.load("/user/123"); assert.deepEqual(receivedParams, { id: "123" }); }); it("should blur a focused control on the page being hidden", async () => { const router = new Router(); const { root } = mountRouter(router); document.body.appendChild(root); router.addRoute("/search", () => Promise.resolve({})); router.addRoute("/profile", () => Promise.resolve({})); let renderCount = 0; router.renderRoute(({ container }) => { if (renderCount++ === 0) { const input = document.createElement("input"); container.appendChild(input); } }); try { await router.load("/search"); const input = router.currentPage.querySelector("input"); input.focus(); assert.deepEqual(document.activeElement, input); await router.load("/profile"); assert.notDeepEqual(document.activeElement, input); } finally { root.remove(); } }); }); describe("go", () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; it("should emit navigate event before loading the new page", async () => { const router = new Router(); mountRouter(router); router.addRoute("/go-test", () => Promise.resolve({})); const order = []; router.on("navigate", () => order.push("navigate")); router.renderRoute(() => order.push("render")); try { await router.go("/go-test"); } finally { window.history.replaceState(originalState, "", originalPath); } assert.deepEqual(order[0], "navigate"); assert.deepEqual(order[1], "render"); }); it("should store the previous route in history state", async () => { const router = new Router(); mountRouter(router); router.addRoute("/go-prev-test", () => Promise.resolve({})); window.history.replaceState(null, "", "/starting-path"); try { await router.go("/go-prev-test"); assert.deepEqual(window.history.state?.previousRoute, "/starting-path"); assert.deepEqual(window.location.pathname, "/go-prev-test"); } finally { window.history.replaceState(originalState, "", originalPath); } }); it("should replace the current history entry when called with replace: true", async () => { const router = new Router(); mountRouter(router); router.addRoute("/go-replace-test", () => Promise.resolve({})); window.history.replaceState(null, "", "/starting-path"); const lengthBefore = window.history.length; try { await router.go("/go-replace-test", { replace: true }); assert.deepEqual(window.location.pathname, "/go-replace-test"); assert.deepEqual(window.history.length, lengthBefore); assert.deepEqual(window.history.state?.previousRoute, undefined); } finally { window.history.replaceState(originalState, "", originalPath); } }); it("should treat navigation to the current path as a no-op", async () => { const router = new Router(); mountRouter(router); router.addRoute("/go-same-test", () => Promise.resolve({})); router.renderRoute(() => {}); try { await router.go("/go-same-test"); const page = router.currentPage; const stateBefore = window.history.state; await router.go("/go-same-test"); assert.deepEqual(router.currentPage, page); assert(page.classList.contains("page-visible")); assert(!page.classList.contains("page-hidden")); // No duplicate history entry pointing back at the same path. assert.deepEqual(window.history.state, stateBefore); } finally { window.history.replaceState(originalState, "", originalPath); } }); }); describe("modifier-click navigation", () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; const originalOpen = window.open; let openMock; let button; beforeEach(() => { openMock = mock.fn(); window.open = openMock; button = document.createElement("button"); document.body.appendChild(button); }); afterEach(() => { window.open = originalOpen; button.remove(); window.history.replaceState(originalState, "", originalPath); }); function click(modifiers = {}) { button.dispatchEvent(new MouseEvent("click", modifiers)); } it("should open in a new tab on cmd+click instead of navigating", () => { const router = new Router(); mountRouter(router); router.addRoute("/meta-test", () => Promise.resolve({})); let navigated = false; router.on("navigate", () => (navigated = true)); window.history.replaceState(null, "", "/starting-path"); button.addEventListener("click", () => router.go("/meta-test")); click({ metaKey: true }); assert.deepEqual(openMock.mock.callCount(), 1); assert.deepEqual(openMock.mock.calls[0].arguments, [ "/meta-test", "_blank", "noopener", ]); assert.deepEqual(navigated, false); assert.deepEqual(window.location.pathname, "/starting-path"); }); it("should open in a new tab on ctrl+click", () => { const router = new Router(); button.addEventListener("click", () => router.go("/ctrl-test")); click({ ctrlKey: true }); assert.deepEqual( openMock.mock.calls.map((call) => call.arguments), [["/ctrl-test", "_blank", "noopener"]], ); }); it("should navigate normally on unmodified click", () => { const router = new Router(); mountRouter(router); router.addRoute("/plain-test", () => Promise.resolve({})); button.addEventListener("click", () => router.go("/plain-test")); click(); assert.deepEqual(openMock.mock.callCount(), 0); assert.deepEqual(window.location.pathname, "/plain-test"); }); it("should open in a new tab on cmd+Enter", () => { const router = new Router(); button.addEventListener("keydown", () => router.go("/enter-test")); button.dispatchEvent( new KeyboardEvent("keydown", { key: "Enter", metaKey: true }), ); assert.deepEqual( openMock.mock.calls.map((call) => call.arguments), [["/enter-test", "_blank", "noopener"]], ); }); it("should navigate normally when metaKey is held on a non-Enter key", () => { const router = new Router(); mountRouter(router); router.addRoute("/keyboard-test", () => Promise.resolve({})); button.addEventListener("keydown", () => router.go("/keyboard-test")); button.dispatchEvent( new KeyboardEvent("keydown", { key: "k", metaKey: true }), ); assert.deepEqual(openMock.mock.callCount(), 0); assert.deepEqual(window.location.pathname, "/keyboard-test"); }); it("should open the previous route in a new tab on cmd+click of back", () => { const router = new Router(); window.history.replaceState({ previousRoute: "/prior-path" }, "", "/here"); button.addEventListener("click", () => router.back()); click({ metaKey: true }); assert.deepEqual( openMock.mock.calls.map((call) => call.arguments), [["/prior-path", "_blank", "noopener"]], ); assert.deepEqual(window.location.pathname, "/here"); }); it("should open home in a new tab on cmd+click of back without history", () => { const router = new Router(); window.history.replaceState(null, "", "/here"); button.addEventListener("click", () => router.back()); click({ metaKey: true }); assert.deepEqual( openMock.mock.calls.map((call) => call.arguments), [["/", "_blank", "noopener"]], ); }); }); describe("middle-click navigation", () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; const originalOpen = window.open; let openMock; let button; beforeEach(() => { openMock = mock.fn(); window.open = openMock; button = document.createElement("button"); document.body.appendChild(button); }); afterEach(() => { window.open = originalOpen; button.remove(); window.history.replaceState(originalState, "", originalPath); }); function middleClick(target, options = {}) { target.dispatchEvent( new MouseEvent("auxclick", { button: 1, bubbles: true, ...options }), ); } it("should open in a new tab on middle click instead of navigating", () => { const router = new Router(); mountRouter(router); router.addRoute("/middle-test", () => Promise.resolve({})); let navigated = false; router.on("navigate", () => (navigated = true)); window.history.replaceState(null, "", "/starting-path"); button.addEventListener("click", () => router.go("/middle-test")); middleClick(button); assert.deepEqual( openMock.mock.calls.map((call) => call.arguments), [["/middle-test", "_blank", "noopener"]], ); assert.deepEqual(navigated, false); assert.deepEqual(window.location.pathname, "/starting-path"); }); it("should open the previous route in a new tab on middle click of back", () => { const router = new Router(); window.history.replaceState({ previousRoute: "/prior-path" }, "", "/here"); button.addEventListener("click", () => router.back()); middleClick(button); assert.deepEqual( openMock.mock.calls.map((call) => call.arguments), [["/prior-path", "_blank", "noopener"]], ); assert.deepEqual(window.location.pathname, "/here"); }); it("should ignore auxclicks from non-middle buttons", () => { new Router(); const clickHandler = mock.fn(); button.addEventListener("click", clickHandler); middleClick(button, { button: 2 }); assert.deepEqual(clickHandler.mock.callCount(), 0); assert.deepEqual(openMock.mock.callCount(), 0); }); it("should leave middle clicks on anchors to native handling", () => { new Router(); const anchor = document.createElement("a"); anchor.href = "/native-link"; button.appendChild(anchor); const clickHandler = mock.fn(); button.addEventListener("click", clickHandler); middleClick(anchor); assert.deepEqual(clickHandler.mock.callCount(), 0); assert.deepEqual(openMock.mock.callCount(), 0); }); it("should not re-dispatch when the auxclick default is prevented", () => { new Router(); button.addEventListener("auxclick", (event) => event.preventDefault()); const clickHandler = mock.fn(); button.addEventListener("click", clickHandler); middleClick(button, { cancelable: true }); assert.deepEqual(clickHandler.mock.callCount(), 0); assert.deepEqual(openMock.mock.callCount(), 0); }); }); describe("previousRoute", () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; it("should return null when history state has no previousRoute", () => { const router = new Router(); window.history.replaceState(null, "", originalPath); try { assert.deepEqual(router.previousRoute, null); } finally { window.history.replaceState(originalState, "", originalPath); } }); it("should return the previousRoute stored in history state", () => { const router = new Router(); window.history.replaceState( { previousRoute: "/some/prior/path" }, "", originalPath, ); try { assert.deepEqual(router.previousRoute, "/some/prior/path"); } finally { window.history.replaceState(originalState, "", originalPath); } }); it("should reflect the previousRoute after a go() call", async () => { const router = new Router(); mountRouter(router); router.addRoute("/prev-getter-test", () => Promise.resolve({})); window.history.replaceState(null, "", "/origin-path"); try { await router.go("/prev-getter-test"); assert.deepEqual(router.previousRoute, "/origin-path"); } finally { window.history.replaceState(originalState, "", originalPath); } }); }); describe("back", () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; it("should call window.history.back when a previousRoute exists", async () => { const router = new Router(); mountRouter(router); window.history.replaceState({ previousRoute: "/prior" }, "", originalPath); const originalBack = window.history.back.bind(window.history); let backCalled = false; window.history.back = () => { backCalled = true; }; try { await router.back(); assert(backCalled); } finally { window.history.back = originalBack; window.history.replaceState(originalState, "", originalPath); } }); it("should navigate to / when no previousRoute exists", async () => { const router = new Router(); mountRouter(router); router.addRoute("/", () => Promise.resolve({})); window.history.replaceState(null, "", originalPath); const originalBack = window.history.back.bind(window.history); let backCalled = false; window.history.back = () => { backCalled = true; }; try { await router.back(); assert(!backCalled); assert.deepEqual(window.location.pathname, "/"); } finally { window.history.back = originalBack; window.history.replaceState(originalState, "", originalPath); } }); it("should navigate to fallbackRoute when no previousRoute exists", async () => { const router = new Router(); mountRouter(router); router.addRoute("/messages", () => Promise.resolve({})); window.history.replaceState(null, "", originalPath); try { await router.back({ fallbackRoute: "/messages" }); assert.deepEqual(window.location.pathname, "/messages"); } finally { window.history.replaceState(originalState, "", originalPath); } }); it("should ignore fallbackRoute when a previousRoute exists", async () => { const router = new Router(); mountRouter(router); window.history.replaceState({ previousRoute: "/prior" }, "", originalPath); const originalBack = window.history.back.bind(window.history); let backCalled = false; window.history.back = () => { backCalled = true; }; const pathBefore = window.location.pathname; try { await router.back({ fallbackRoute: "/messages" }); assert(backCalled); assert.deepEqual(window.location.pathname, pathBefore); } finally { window.history.back = originalBack; window.history.replaceState(originalState, "", originalPath); } }); it("should replace history when falling back, not push", async () => { const router = new Router(); mountRouter(router); router.addRoute("/messages", () => Promise.resolve({})); window.history.replaceState(null, "", "/deep-link"); const lengthBefore = window.history.length; try { await router.back({ fallbackRoute: "/messages" }); assert.deepEqual(window.location.pathname, "/messages"); assert.deepEqual(window.history.length, lengthBefore); assert.deepEqual(router.previousRoute, null); } finally { window.history.replaceState(originalState, "", originalPath); } }); }); describe("redirect routes", () => { const originalPath = window.location.pathname + window.location.search + window.location.hash; const originalState = window.history.state; afterEach(() => { window.history.replaceState(originalState, "", originalPath); }); function createRouter() { const router = new Router(); mountRouter(router); router.addRedirects({ "/old": () => "/new", "/old/:id": (params) => `/new/${params.id}`, }); router.addRoute("/new", () => Promise.resolve({ name: "new" })); router.addRoute("/new/:id", () => Promise.resolve({ name: "newDetail" })); router.renderRoute(() => {}); return router; } it("keeps redirects separate from routes", () => { const router = createRouter(); assert.deepEqual(router.hasRoute("/old"), false); assert.deepEqual(router.matchRedirect("/old"), "/new"); assert.deepEqual(router.matchRedirect("/old/123"), "/new/123"); assert.deepEqual(router.matchRedirect("/unrelated"), null); }); it("loads the target route and replaces the URL", async () => { const router = createRouter(); await router.load("/old"); assert.deepEqual(window.location.pathname, "/new"); assert.deepEqual(router.$currentRoute.get().route, "/new"); }); it("passes route params to the redirect function", async () => { const router = createRouter(); await router.load("/old/123"); assert.deepEqual(window.location.pathname, "/new/123"); assert.deepEqual(router.$currentRoute.get().params, { id: "123" }); }); it("preserves the query string across the redirect", async () => { const router = createRouter(); await router.load("/old?foo=bar&baz=1"); assert.deepEqual(window.location.pathname, "/new"); assert.deepEqual(window.location.search, "?foo=bar&baz=1"); assert.deepEqual(router.$currentRoute.get().route, "/new"); }); it("replaces the history entry instead of pushing", async () => { const router = createRouter(); window.history.replaceState(null, "", "/old"); const lengthBefore = window.history.length; await router.load("/old"); assert.deepEqual(window.history.length, lengthBefore); }); it("preserves the existing history state across the redirect", async () => { const router = createRouter(); window.history.replaceState({ previousRoute: "/prior" }, "", "/old"); await router.load("/old"); assert.deepEqual(window.history.state?.previousRoute, "/prior"); }); it("awaits an async redirect resolver", async () => { const router = createRouter(); router.addRedirects({ "/async-old": async () => "/new" }); await router.load("/async-old"); assert.deepEqual(window.location.pathname, "/new"); assert.deepEqual(router.$currentRoute.get().route, "/new"); }); it("falls through to the matched route when an async resolver returns null", async () => { const router = createRouter(); router.addRedirects({ "/new": async () => null }); await router.load("/new"); assert.deepEqual(router.currentPath, "/new"); assert.deepEqual(router.$currentRoute.get().route, "/new"); }); it("abandons a pending async redirect when another load starts", async () => { const router = createRouter(); let resolveRedirect; router.addRedirects({ "/slow-old": () => new Promise((resolve) => { resolveRedirect = resolve; }), }); const slowLoad = router.load("/slow-old"); await router.load("/new"); resolveRedirect("/new/123"); await slowLoad; assert.deepEqual(router.currentPath, "/new"); assert.deepEqual(router.$currentRoute.get().route, "/new"); assert.notDeepEqual(window.location.pathname, "/new/123"); }); }); describe("route options", () => { it("stores options on the route and returns them from match", () => { const router = new Router(); const options = { layoutOptions: { activeNavItem: "home" } }; router.addRoute("/test", () => {}, options); assert.deepEqual(router.match("/test").options, options); }); it("defaults options to an empty object", () => { const router = new Router(); router.addRoute("/test", () => {}); assert.deepEqual(router.match("/test").options, {}); }); it("returns notFound options for unmatched paths", () => { const router = new Router(); router.setNotFoundView(() => {}, { layout: false }); assert.deepEqual(router.match("/nope").options, { layout: false }); }); it("appends pages to the default container by default", async () => { const router = new Router(); const { defaultContainer, bareContainer } = mountRouter(router); router.addRoute("/test", () => Promise.resolve({})); router.renderRoute(() => {}); await router.load("/test"); assert.deepEqual(router.currentPage.parentElement, defaultContainer); assert(!bareContainer.contains(router.currentPage)); }); it("passes the layout to renders of layout routes only", async () => { const router = new Router(); const layout = new TestLayout(); mountRouter(router, { layout }); router.addRoute("/test", () => Promise.resolve({})); router.addRoute("/login", () => Promise.resolve({}), { layout: false }); let receivedLayout; router.renderRoute(({ layout: renderLayout }) => { receivedLayout = renderLayout; }); await router.load("/test"); assert.deepEqual(receivedLayout, layout); await router.load("/login"); assert.deepEqual(receivedLayout, null); }); it("appends pages to the bare container when layout is false", async () => { const router = new Router(); const { defaultContainer, bareContainer } = mountRouter(router); router.addRoute("/login", () => Promise.resolve({}), { layout: false }); router.renderRoute(() => {}); await router.load("/login"); assert.deepEqual(router.currentPage.parentElement, bareContainer); }); }); describe("layout visibility", () => { function createRouterWithLayout() { const router = new Router(); const layout = new TestLayout(); const { layoutContainer } = mountRouter(router, { layout }); const isLayoutHidden = () => layoutContainer.classList.contains("layout-hidden"); router.addRoute("/test", () => Promise.resolve({})); router.addRoute("/login", () => Promise.resolve({}), { layout: false }); router.renderRoute(() => {}); return { router, isLayoutHidden }; } it("hides the layout before rendering a new bare page", async () => { const { router, isLayoutHidden } = createRouterWithLayout(); let hiddenAtRenderTime = null; router.renderRoute(() => { hiddenAtRenderTime = isLayoutHidden(); }); await router.load("/login"); assert.deepEqual(hiddenAtRenderTime, true); await router.load("/test"); assert.deepEqual(hiddenAtRenderTime, false); }); it("shows the layout before a cached page's restore handlers run", async () => { const { router, isLayoutHidden } = createRouterWithLayout(); await router.load("/test"); await router.load("/login"); assert.deepEqual(isLayoutHidden(), true); // Views restore scroll synchronously in page-show handlers, so the // layout must already be visible when the event fires let hiddenAtRestoreTime = null; const { el: testPage } = router.pages.get("/test"); testPage.addEventListener("page-show", () => { hiddenAtRestoreTime = isLayoutHidden(); }); await router.load("/test", { isRestore: true }); assert.deepEqual(hiddenAtRestoreTime, false); }); }); describe("$currentRoute", () => { function createRouter() { const router = new Router(); mountRouter(router); router.addRoute("/user/:id", () => Promise.resolve({}), { layoutOptions: { activeNavItem: "profile" }, }); router.addRoute("/other", () => Promise.resolve({})); router.renderRoute(() => {}); return router; } it("is set when loading a new page", async () => { const router = createRouter(); await router.load("/user/123?tab=posts"); assert.deepEqual(router.$currentRoute.get(), { path: "/user/123?tab=posts", route: "/user/:id", params: { id: "123" }, options: { layoutOptions: { activeNavItem: "profile" } }, }); }); it("is set when restoring a cached page", async () => { const router = createRouter(); await router.load("/user/123"); await router.load("/other"); await router.load("/user/123"); assert.deepEqual(router.$currentRoute.get().route, "/user/:id"); assert.deepEqual(router.$currentRoute.get().params, { id: "123" }); }); }); describe("page cache", () => { // mountRouter's root is detached, so "still rendered" means "still in the // page container" rather than isConnected function createRouter(paths) { const router = new Router(); const { defaultContainer } = mountRouter(router); for (const path of paths) { router.addRoute(path, () => Promise.resolve({})); } router.renderRoute(() => {}); return { router, defaultContainer }; } it("drops the least recently visited page once over the cap", async () => { const paths = ["/a", "/b", "/c", "/d", "/e", "/f"]; const { router, defaultContainer } = createRouter(paths); for (const path of paths.slice(0, 5)) { await router.load(path); } // Revisiting /a makes /b the coldest page await router.load("/a"); // peek, not get: reading through the cache would count as a visit const pageB = router.pages.peek("/b").el; await router.load("/f"); assert.deepEqual(router.pages.has("/b"), false); assert.deepEqual(defaultContainer.contains(pageB), false); assert(router.pages.has("/a"), "the revisited page is kept"); assert.deepEqual(router.pages.size, 5); }); it("keeps a returned-to page rendered", async () => { const { router, defaultContainer } = createRouter(["/a", "/b"]); await router.load("/a"); const pageA = router.pages.peek("/a").el; await router.load("/b"); await router.load("/a"); assert.equal(router.pages.peek("/a").el, pageA); assert(defaultContainer.contains(pageA)); }); }); describe("scroll position persistence", () => { function createRouter() { const router = new Router(); mountRouter(router); router.addRoute("/a", () => Promise.resolve({})); router.addRoute("/b", () => Promise.resolve({})); router.renderRoute(() => {}); return router; } it("saves the scroll position of the page being navigated away from", async () => { const router = createRouter(); await router.load("/a"); await withScrollY(250, () => router.load("/b")); assert.deepEqual(router.scrollStates.get("/a"), 250); }); it("does not record a scroll position on the very first load", async () => { const router = createRouter(); await withScrollY(250, () => router.load("/a")); assert.deepEqual(router.scrollStates.has("/a"), false); }); it("getScrollYForPath returns live scrollY for the current path and the saved value for others", async () => { const router = createRouter(); await router.load("/a"); await withScrollY(175, async () => { await router.load("/b"); // leaving /a saves 175 under /a }); await withScrollY(42, () => { assert.deepEqual(router.getScrollYForPath("/b"), 42); assert.deepEqual(router.getScrollYForPath("/a"), 175); assert.deepEqual(router.getScrollYForPath("/never-visited"), 0); }); }); it("restores the saved scroll position via the page-show event", async () => { const router = createRouter(); await router.load("/a"); const pageA = router.pages.get("/a").el; let restoredScrollY = null; pageA.addEventListener("page-show", (event) => { restoredScrollY = event.detail.scrollY; }); await withScrollY(175, async () => { await router.load("/b"); // leaving /a saves 175 under /a await router.load("/a", { isRestore: true }); // back to cached /a restores it }); assert.deepEqual(restoredScrollY, 175); }); }); describe("scrollRestore", () => { let scrollTo; beforeEach(() => { scrollTo = mock.method(window, "scrollTo", () => {}); }); afterEach(() => { scrollTo.mock.restore(); }); function scrollCalls() { return scrollTo.mock.calls.map((call) => call.arguments); } function createRouter(scrollRestore) { const router = new Router(); mountRouter(router); router.addRoute( "/a", () => Promise.resolve({}), scrollRestore ? { scrollRestore } : {}, ); router.addRoute("/b", () => Promise.resolve({})); router.renderRoute(() => {}); return router; } // Returns to /a after leaving it scrolled to 175 async function revisit(router, { isRestore }) { await router.load("/a"); await withScrollY(175, () => router.load("/b")); scrollTo.mock.resetCalls(); await router.load("/a", { isRestore }); } it("restores the saved position on back navigation by default", async () => { const router = createRouter(); await revisit(router, { isRestore: true }); assert.deepEqual(scrollCalls(), [[0, 175]]); }); it("scrolls to the top on forward navigation by default", async () => { const router = createRouter(); await revisit(router, { isRestore: false }); assert.deepEqual(scrollCalls(), [[0, 0]]); }); it("restores the saved position on forward navigation when set to always", async () => { const router = createRouter("always"); await revisit(router, { isRestore: false }); assert.deepEqual(scrollCalls(), [[0, 175]]); }); it("still restores the saved position on back navigation when set to always", async () => { const router = createRouter("always"); await revisit(router, { isRestore: true }); assert.deepEqual(scrollCalls(), [[0, 175]]); }); it("does not touch the scroll position when set to manual", async () => { const router = createRouter("manual"); await revisit(router, { isRestore: false }); await revisit(router, { isRestore: true }); assert.deepEqual(scrollCalls(), []); }); it("scrolls before dispatching page-show so a view can override it", async () => { const router = createRouter(); await router.load("/a"); let scrolledBeforeDispatch = null; router.pages.get("/a").el.addEventListener("page-show", () => { scrolledBeforeDispatch = scrollCalls(); }); await revisit(router, { isRestore: true }); assert.deepEqual(scrolledBeforeDispatch, [[0, 175]]); }); }); describe("page event dispatch", () => { function createRouter() { const router = new Router(); router.renderRoute(() => {}); router.addRoute("/a", () => () => {}); router.addRoute("/b", () => () => {}); mountRouter(router); return router; } async function showDetailsFor(loadOptions) { const router = createRouter(); await router.load("/a"); const pageA = router.pages.get("/a").el; const details = []; pageA.addEventListener("page-show", (event) => details.push(event.detail)); await router.load("/b"); await router.load("/a", loadOptions); return details; } it("reports an advance action when a cached page is revisited forwards", async () => { assert.deepEqual(await showDetailsFor({}), [ { scrollY: 0, action: "advance" }, ]); }); it("reports a restore action on a history traversal", async () => { assert.deepEqual(await showDetailsFor({ isRestore: true }), [ { scrollY: 0, action: "restore" }, ]); }); it("dispatches a single page-show with an advance action on first load", async () => { const router = new Router(); router.addRoute("/a", () => () => {}); mountRouter(router); const details = []; router.renderRoute(({ container }) => { container.addEventListener("page-show", (event) => details.push(event.detail), ); }); await router.load("/a"); assert.deepEqual(details, [{ scrollY: 0, action: "advance" }]); }); it("reports the restored scroll position on page-show", async () => { const router = createRouter(); await router.load("/a"); const details = []; router.pages .get("/a") .el.addEventListener("page-show", (event) => details.push(event.detail)); await withScrollY(120, async () => { await router.load("/b"); await router.load("/a", { isRestore: true }); }); assert.deepEqual(details, [{ scrollY: 120, action: "restore" }]); }); it("does not re-run a view when its cached page is revisited", async () => { const router = new Router(); router.addRoute("/a", () => () => {}); router.addRoute("/b", () => () => {}); mountRouter(router); const rendered = []; router.renderRoute(({ container }) => rendered.push(container)); await router.load("/a"); await router.load("/b"); await router.load("/a"); assert.deepEqual(rendered.length, 2); }); });