UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

392 lines (391 loc) 14.7 kB
import { beforeEach, describe, expect, it, vi } from "vitest"; import { initClientNavigation, validateClickEvent } from "./navigation"; import { getNavigationSnapshot, resetNavigationStateForTests, } from "./navigationState"; import { HISTORY_STATE_SCROLL_KEY } from "./scrollRestoration"; beforeEach(() => { resetNavigationStateForTests(); }); // Mocking browser globals vi.stubGlobal("window", { location: { href: "http://localhost/" }, addEventListener: vi.fn(), history: { scrollRestoration: "auto", pushState: vi.fn(), replaceState: vi.fn(), state: {}, }, scrollX: 0, scrollY: 0, }); vi.stubGlobal("document", { addEventListener: vi.fn(), }); vi.stubGlobal("history", { scrollRestoration: "auto", }); vi.stubGlobal("Headers", class { constructor(init) { this.map = {}; if (init && init.Location) { this.map.location = init.Location; } } get(name) { return this.map[name.toLowerCase()] || null; } }); describe("clientNavigation", () => { let mockEvent = { button: 0, metaKey: false, altKey: false, shiftKey: false, ctrlKey: false, }; let mockTarget = { closest: () => { return { getAttribute: () => "/test", hasAttribute: () => false, }; }, }; it("should return true", () => { expect(validateClickEvent(mockEvent, mockTarget)).toBe(true); }); it("should return false if the event is not a left click", () => { expect(validateClickEvent({ ...mockEvent, button: 1 }, mockTarget)).toBe(false); }); it("none of the modifier keys are pressed", () => { expect(validateClickEvent({ ...mockEvent, metaKey: true }, mockTarget)).toBe(false); }); it("the target is not an anchor tag", () => { expect(validateClickEvent(mockEvent, { closest: () => undefined, })).toBe(false); }); it("should have an href attribute", () => { expect(validateClickEvent(mockEvent, { closest: () => ({ getAttribute: () => undefined }), })).toBe(false); }); it("should not include an #hash", () => { expect(validateClickEvent(mockEvent, { closest: () => ({ getAttribute: () => "/test#hash", hasAttribute: () => false, }), })).toBe(false); }); it("should be a relative link", () => { expect(validateClickEvent(mockEvent, { closest: () => ({ getAttribute: () => "/test", hasAttribute: () => false, }), })).toBe(true); }); }); // Regression tests for issue #1123: onNavigate callback was never called // Root cause: commit c543ef7 extracted navigate() but dropped onNavigate wiring describe("onNavigate callback (issue #1123 regression)", () => { let capturedClickHandler = null; let capturedPopstateHandler = null; beforeEach(() => { capturedClickHandler = null; capturedPopstateHandler = null; vi.clearAllMocks(); let historyState = {}; // Capture registered event listeners so we can invoke them manually vi.stubGlobal("document", { visibilityState: "visible", querySelectorAll: vi.fn().mockReturnValue([]), addEventListener: vi.fn((event, handler) => { if (event === "click") capturedClickHandler = handler; }), }); vi.stubGlobal("window", { location: { href: "http://localhost/", pathname: "/", search: "" }, addEventListener: vi.fn((event, handler) => { if (event === "popstate") capturedPopstateHandler = handler; }), history: { scrollRestoration: "auto", get state() { return historyState; }, pushState: vi.fn((state) => { historyState = state; }), replaceState: vi.fn((state) => { historyState = state; }), }, scrollX: 0, scrollY: 0, scrollTo: vi.fn(), }); vi.stubGlobal("history", { scrollRestoration: "auto", pushState: vi.fn(), replaceState: vi.fn(), state: {}, }); // Assign directly to globalThis without replacing it (avoids breaking Vitest internals) globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined); }); it("onNavigate is called during link click navigation", async () => { const onNavigate = vi.fn(); initClientNavigation({ onNavigate }); expect(capturedClickHandler).not.toBeNull(); const fakeAnchor = { getAttribute: (attr) => (attr === "href" ? "/about" : null), hasAttribute: () => false, target: "", closest: (sel) => (sel === "a" ? fakeAnchor : null), }; const fakeTarget = { closest: (sel) => (sel === "a" ? fakeAnchor : null), }; const fakeClickEvent = { button: 0, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, target: fakeTarget, preventDefault: vi.fn(), }; await capturedClickHandler(fakeClickEvent); expect(onNavigate).toHaveBeenCalled(); }); it("onNavigate is called during popstate navigation", async () => { const onNavigate = vi.fn(); initClientNavigation({ onNavigate }); expect(capturedPopstateHandler).not.toBeNull(); window.location.pathname = "/about"; await capturedPopstateHandler(); expect(onNavigate).toHaveBeenCalled(); }); it("onNavigate fires after pushState but before RSC fetch", async () => { const callOrder = []; const onNavigate = vi.fn(() => { callOrder.push("onNavigate"); }); globalThis.__rsc_callServer = vi.fn(() => { callOrder.push("rscCallServer"); return Promise.resolve(); }); initClientNavigation({ onNavigate }); const fakeAnchor = { getAttribute: (attr) => (attr === "href" ? "/about" : null), hasAttribute: () => false, target: "", closest: (sel) => (sel === "a" ? fakeAnchor : null), }; const fakeTarget = { closest: (sel) => (sel === "a" ? fakeAnchor : null), }; const fakeClickEvent = { button: 0, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, target: fakeTarget, preventDefault: vi.fn(), }; await capturedClickHandler(fakeClickEvent); expect(callOrder).toEqual(["onNavigate", "rscCallServer"]); expect(window.history.pushState).toHaveBeenCalled(); }); }); describe("initClientNavigation", () => { let historyState; let capturedScrollHandler = null; let capturedPagehideHandler = null; let capturedVisibilityChangeHandler = null; let capturedPopstateHandler = null; beforeEach(() => { historyState = {}; capturedScrollHandler = null; capturedPagehideHandler = null; capturedVisibilityChangeHandler = null; capturedPopstateHandler = null; vi.clearAllMocks(); const mockHistory = { scrollRestoration: "auto", get state() { return historyState; }, pushState: vi.fn((state) => { historyState = state; }), replaceState: vi.fn((state) => { historyState = state; }), }; vi.stubGlobal("document", { visibilityState: "visible", querySelectorAll: vi.fn().mockReturnValue([]), addEventListener: vi.fn((event, handler) => { if (event === "visibilitychange") { capturedVisibilityChangeHandler = handler; } }), }); vi.stubGlobal("window", { location: { href: "http://localhost/", pathname: "/", search: "" }, addEventListener: vi.fn((event, handler) => { if (event === "scroll") { capturedScrollHandler = handler; } if (event === "pagehide") { capturedPagehideHandler = handler; } if (event === "popstate") { capturedPopstateHandler = handler; } }), history: mockHistory, fetch: vi.fn(), scrollX: 0, scrollY: 0, scrollTo: vi.fn(), }); vi.stubGlobal("history", mockHistory); }); it("handleResponse should follow redirects", () => { const { handleResponse } = initClientNavigation(); const mockResponse = { status: 302, headers: new Headers({ Location: "/new-page" }), ok: false, }; const result = handleResponse(mockResponse); expect(result).toBe(false); expect(window.location.href).toBe("/new-page"); }); it("handleResponse should reload on error", () => { const { handleResponse } = initClientNavigation(); const mockResponse = { status: 500, ok: false, }; const result = handleResponse(mockResponse); expect(result).toBe(false); expect(window.location.href).toBe("http://localhost/"); }); it("sets history.scrollRestoration to manual so the browser does not restore scroll before the RSC payload commits", () => { history.scrollRestoration = "auto"; initClientNavigation(); expect(history.scrollRestoration).toBe("manual"); }); it("ignores hash-only popstate events so anchor links keep their native scroll", async () => { const onNavigate = vi.fn(); globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined); const { onHydrated } = initClientNavigation({ onNavigate }); expect(capturedPopstateHandler).not.toBeNull(); window.location.hash = "#heading"; window.location.href = "http://localhost/#heading"; window.scrollY = 500; await capturedPopstateHandler(); onHydrated(); expect(onNavigate).not.toHaveBeenCalled(); expect(globalThis.__rsc_callServer).not.toHaveBeenCalled(); expect(window.scrollTo).not.toHaveBeenCalled(); }); it("keeps navigation pending until the matching navigation payload hydrates", async () => { globalThis.__rsc_callServer = vi.fn().mockResolvedValue(undefined); const { onHydrated } = initClientNavigation(); expect(capturedPopstateHandler).not.toBeNull(); window.location.href = "http://localhost/about"; window.location.pathname = "/about"; await capturedPopstateHandler(); const pending = getNavigationSnapshot().pending; expect(pending?.pendingUrl.href).toBe("http://localhost/about"); onHydrated({ source: "action", href: pending.pendingUrl.href }); expect(getNavigationSnapshot().pending?.id).toBe(pending.id); onHydrated({ source: "navigation", href: "http://localhost/other" }); expect(getNavigationSnapshot().pending?.id).toBe(pending.id); onHydrated({ source: "navigation", href: pending.pendingUrl.href }); expect(getNavigationSnapshot().pending).toBeNull(); }); it("does not write to history state on scroll", () => { initClientNavigation(); expect(capturedScrollHandler).not.toBeNull(); vi.mocked(window.history.replaceState).mockClear(); window.scrollY = 100; capturedScrollHandler(); window.scrollY = 200; capturedScrollHandler(); expect(window.history.replaceState).not.toHaveBeenCalled(); }); it("restores scroll from persisted history state after reload", () => { historyState = { [HISTORY_STATE_SCROLL_KEY]: "entry:1", scrollX: 9, scrollY: 321, }; const { onHydrated } = initClientNavigation(); onHydrated(); expect(window.scrollTo).toHaveBeenCalledWith({ left: 9, top: 321, behavior: "instant", }); }); it("migrates legacy scrollX/scrollY state on boot", () => { historyState = { scrollX: 11, scrollY: 432 }; const { onHydrated } = initClientNavigation(); onHydrated(); expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({ [HISTORY_STATE_SCROLL_KEY]: expect.any(String), scrollX: 11, scrollY: 432, }), "", "http://localhost/"); expect(window.scrollTo).toHaveBeenCalledWith({ left: 11, top: 432, behavior: "instant", }); }); it("flushes the latest scroll position on pagehide for reload restoration", () => { initClientNavigation(); expect(capturedScrollHandler).not.toBeNull(); expect(capturedPagehideHandler).not.toBeNull(); vi.mocked(window.history.replaceState).mockClear(); window.scrollX = 3; window.scrollY = 250; capturedScrollHandler(); capturedPagehideHandler(); expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({ [HISTORY_STATE_SCROLL_KEY]: expect.any(String), scrollX: 3, scrollY: 250, }), "", "http://localhost/"); }); it("flushes the latest scroll position when the page is hidden", () => { initClientNavigation(); expect(capturedVisibilityChangeHandler).not.toBeNull(); vi.mocked(window.history.replaceState).mockClear(); window.scrollX = 5; window.scrollY = 275; Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true, }); capturedVisibilityChangeHandler(); expect(window.history.replaceState).toHaveBeenCalledWith(expect.objectContaining({ [HISTORY_STATE_SCROLL_KEY]: expect.any(String), scrollX: 5, scrollY: 275, }), "", "http://localhost/"); }); });