// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, screen, waitFor } from "@testing-library/react";
import React, { type PropsWithChildren } from "react";
import { HashRouterProvider } from "../../router/hash-router.js";
import { useRouter } from "../../router/context.js";
import type { PageInfo } from "../../viewer/types.js";
import type { CoID, RawCoValue } from "cojson";
function Wrapper({ children }: PropsWithChildren) {
return {children};
}
function encodePathToHash(path: PageInfo[]): string {
return path
.map((page) => {
if (page.name && page.name !== "Root") {
return `${page.coId}:${encodeURIComponent(page.name)}`;
}
return page.coId;
})
.join("/");
}
async function setHash(path: PageInfo[]) {
window.history.replaceState({}, "", `#/${encodePathToHash(path)}`);
}
describe("HashRouterProvider", () => {
afterEach(async () => {
setHash([]);
expect(window.location.hash).toBe("#/");
});
describe("initialization", () => {
it("should initialize with empty path when no hash and no defaultPath", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
expect(window.location.hash).toBe("#/");
});
});
it("should initialize with defaultPath when provided", async () => {
const defaultPath: PageInfo[] = [
{ coId: "co_test1" as CoID, name: "Test1" },
{ coId: "co_test2" as CoID, name: "Test2" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
expect(result.current.path).toEqual(defaultPath);
await waitFor(() => {
expect(window.location.hash).toBe(`#/${encodePathToHash(defaultPath)}`);
});
});
it("should initialize from hash when available", async () => {
const storedPath: PageInfo[] = [
{ coId: "co_stored1" as CoID, name: "Stored1" },
];
await setHash(storedPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(storedPath);
expect(window.location.hash).toBe(`#/${encodePathToHash(storedPath)}`);
});
});
it("should sync defaultPath over hash when defaultPath is provided", async () => {
const storedPath: PageInfo[] = [
{ coId: "co_stored" as CoID, name: "Stored" },
];
const defaultPath: PageInfo[] = [
{ coId: "co_default" as CoID, name: "Default" },
];
await setHash(storedPath);
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(defaultPath);
expect(window.location.hash).toBe(`#/${encodePathToHash(defaultPath)}`);
});
});
it("should handle invalid hash gracefully", async () => {
// The decodePathFromHash function doesn't actually throw errors for invalid formats
// It just parses what it can. So we test with a malformed hash that might cause issues
await setHash([
{
coId: "invalid:hash:format" as CoID,
name: "Invalid Hash Format",
},
]);
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
// Wait for initialization
await waitFor(() => {
// The hash will be parsed, might not be empty
expect(result.current.path).toBeDefined();
});
// decodePathFromHash doesn't throw, it just parses segments
// So we might not get an error, but the path should be valid
expect(Array.isArray(result.current.path)).toBe(true);
consoleErrorSpy.mockRestore();
});
it("should handle SSR scenario - component initializes with empty path when no defaultPath", async () => {
// In SSR, window is undefined, so the initial state should be empty array
// We test this by ensuring the component works correctly without hash
// Note: We can't actually set window to undefined in happy-dom environment
// So we just verify it works when hash is empty
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
// When no hash and no defaultPath, should initialize with empty array
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
it("should decode hash with names correctly", async () => {
const path: PageInfo[] = [
{ coId: "co_test1" as CoID, name: "Test Name" },
{ coId: "co_test2" as CoID, name: "Another Name" },
];
await setHash(path);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(path);
});
});
it("should decode hash without names correctly", async () => {
const path: PageInfo[] = [
{ coId: "co_test1" as CoID },
{ coId: "co_test2" as CoID },
];
setHash(path);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(window.location.hash).toBe(`#/${encodePathToHash(path)}`);
expect(result.current.path).toEqual(path);
});
});
it("should handle Root name in hash", async () => {
const path: PageInfo[] = [
{ coId: "co_test1" as CoID, name: "Root" },
];
// Root name should not be encoded in hash
await setHash([{ coId: "co_test1" as CoID, name: "Root" }]);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path[0]?.coId).toBe("co_test1");
// Root name might be undefined or "Root" depending on implementation
});
});
it("should update path when defaultPath changes", async () => {
const initialDefaultPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
];
const newPage: PageInfo = {
coId: "co_page1" as CoID,
name: "Page1",
};
const newDefaultPath: PageInfo[] = [
{ coId: "co_new" as CoID, name: "New" },
];
function WrapperWithInitialPath({ children }: PropsWithChildren) {
const [defaultPath, setDefaultPath] =
React.useState(initialDefaultPath);
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithInitialPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(initialDefaultPath);
});
act(() => {
result.current.addPages([newPage]);
});
await waitFor(() => {
expect(result.current.path).toEqual(
initialDefaultPath.concat([newPage]),
);
expect(window.location.hash).toBe(
`#/${encodePathToHash(initialDefaultPath.concat([newPage]))}`,
);
});
act(() => {
screen.getByRole("button", { name: "Set Default Path" }).click();
});
await waitFor(() => {
expect(result.current.path).toEqual(newDefaultPath);
});
});
});
describe("hash persistence", () => {
it("should persist path changes to hash", async () => {
await setHash([]);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
const newPages: PageInfo[] = [
{ coId: "co_new1" as CoID, name: "New1" },
];
act(() => {
result.current.addPages(newPages);
});
await waitFor(() => {
expect(result.current.path).toEqual(newPages);
const hash = window.location.hash.slice(2); // Remove '#/'
expect(hash).toBeTruthy();
const decoded = hash.split("/").map((segment) => {
const [coId, encodedName] = segment.split(":");
return {
coId,
name: encodedName ? decodeURIComponent(encodedName) : undefined,
} as PageInfo;
});
expect(decoded).toEqual(newPages);
});
});
it("should update hash when path changes", async () => {
// Ensure hash is cleared before starting
await setHash([]);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
const firstPages: PageInfo[] = [
{ coId: "co_first" as CoID, name: "First" },
];
act(() => {
result.current.addPages(firstPages);
});
await waitFor(() => {
expect(result.current.path).toEqual(firstPages);
});
const secondPages: PageInfo[] = [
{ coId: "co_second" as CoID, name: "Second" },
];
act(() => {
result.current.addPages(secondPages);
});
await waitFor(() => {
expect(result.current.path).toEqual([...firstPages, ...secondPages]);
const hash = window.location.hash.slice(2);
const decoded = hash.split("/").map((segment) => {
const [coId, encodedName] = segment.split(":");
return {
coId,
name: encodedName ? decodeURIComponent(encodedName) : undefined,
} as PageInfo;
});
expect(decoded).toHaveLength(2);
expect(decoded[0]).toEqual(firstPages[0]);
expect(decoded[1]).toEqual(secondPages[0]);
});
});
});
describe("hashchange event", () => {
it("should update path when hash changes", async () => {
// Ensure hash is cleared before starting
await setHash([]);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
const newPath: PageInfo[] = [
{ coId: "co_new" as CoID, name: "New" },
];
await setHash(newPath);
// Wait for hashchange event to be processed
await waitFor(
() => {
expect(result.current.path).toEqual(newPath);
},
{ timeout: 1000 },
);
});
it("should use defaultPath when hash is cleared", async () => {
const defaultPath: PageInfo[] = [
{ coId: "co_default" as CoID, name: "Default" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(defaultPath);
});
await setHash([]);
await waitFor(
() => {
expect(result.current.path).toEqual(defaultPath);
},
{ timeout: 1000 },
);
});
it("should handle hash changes in hashchange event", async () => {
// Ensure hash is cleared before starting
await setHash([]);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
const newPath: PageInfo[] = [
{ coId: "co_testhash" as CoID, name: "TestHash" },
];
await setHash(newPath);
// Wait for hashchange to process
await waitFor(
() => {
expect(result.current.path).toEqual(newPath);
},
{ timeout: 1000 },
);
});
});
describe("addPages", () => {
it("should add pages to the current path", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
const newPages: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
];
act(() => {
result.current.addPages(newPages);
});
await waitFor(() => {
expect(result.current.path).toEqual(newPages);
});
});
it("should append pages to existing path", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
const newPages: PageInfo[] = [
{ coId: "co_new" as CoID, name: "New" },
];
act(() => {
result.current.addPages(newPages);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
expect(result.current.path[0]).toEqual(initialPath[0]);
expect(result.current.path[1]).toEqual(newPages[0]);
});
});
it("should handle adding empty array", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.addPages([]);
});
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
});
});
describe("goToIndex", () => {
it("should navigate to a specific index", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goToIndex(1);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
expect(result.current.path[0]).toEqual(initialPath[0]);
expect(result.current.path[1]).toEqual(initialPath[1]);
});
});
it("should navigate to index 0", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goToIndex(0);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
expect(result.current.path[0]).toEqual(initialPath[0]);
});
});
it("should handle going to last index", async () => {
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
function WrapperWithDefaultPath({ children }: PropsWithChildren) {
return (
{children}
);
}
const { result } = renderHook(() => useRouter(), {
wrapper: WrapperWithDefaultPath,
});
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goToIndex(2);
});
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
});
it("should handle empty path", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
act(() => {
result.current.goToIndex(0);
});
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
});
describe("setPage", () => {
it("should set path to a single page with Root name", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
{ coId: "co_initial2" as CoID, name: "Initial2" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
const newCoId = "co_newroot" as CoID;
act(() => {
result.current.setPage(newCoId);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
expect(result.current.path[0]).toEqual({
coId: newCoId,
name: "Root",
});
});
});
it("should replace existing path", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_initial" as CoID, name: "Initial" },
{ coId: "co_initial2" as CoID, name: "Initial2" },
{ coId: "co_initial3" as CoID, name: "Initial3" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
const newCoId = "co_newroot" as CoID;
act(() => {
result.current.setPage(newCoId);
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
const firstPage = result.current.path[0];
expect(firstPage).toBeDefined();
expect(firstPage?.coId).toBe(newCoId);
expect(firstPage?.name).toBe("Root");
});
});
});
describe("goBack", () => {
it("should remove the last page from path", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
expect(result.current.path[0]).toEqual(initialPath[0]);
expect(result.current.path[1]).toEqual(initialPath[1]);
});
});
it("should handle going back from single page", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
it("should handle going back from empty path", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toEqual([]);
});
});
it("should handle multiple goBack calls", async () => {
// Don't use defaultPath here since it will override manual changes
const initialPath: PageInfo[] = [
{ coId: "co_page1" as CoID, name: "Page1" },
{ coId: "co_page2" as CoID, name: "Page2" },
{ coId: "co_page3" as CoID, name: "Page3" },
];
await setHash(initialPath);
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
await waitFor(() => {
expect(result.current.path).toEqual(initialPath);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toHaveLength(2);
});
act(() => {
result.current.goBack();
});
await waitFor(() => {
expect(result.current.path).toHaveLength(1);
expect(result.current.path[0]).toEqual(initialPath[0]);
});
});
});
describe("integration scenarios", () => {
it("should handle complex navigation flow", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(result.current.path).toEqual([]);
const page1: PageInfo = {
coId: "co_page1" as CoID,
name: "Page1",
};
const page2: PageInfo = {
coId: "co_page2" as CoID,
name: "Page2",
};
const page3: PageInfo = {
coId: "co_page3" as CoID,
name: "Page3",
};
act(() => {
result.current.addPages([page1]);
});
expect(result.current.path).toEqual([page1]);
act(() => {
result.current.addPages([page2]);
});
expect(result.current.path).toEqual([page1, page2]);
act(() => {
result.current.addPages([page3]);
});
expect(result.current.path).toEqual([page1, page2, page3]);
act(() => {
result.current.goBack();
});
expect(result.current.path).toEqual([page1, page2]);
act(() => {
result.current.goToIndex(0);
});
expect(result.current.path).toEqual([page1]);
act(() => {
result.current.setPage("co_newroot" as CoID);
});
expect(result.current.path).toEqual([
{ coId: "co_newroot" as CoID, name: "Root" },
]);
});
it("should persist complex navigation flow to hash", async () => {
const { result } = renderHook(() => useRouter(), { wrapper: Wrapper });
expect(result.current.path).toEqual([]);
const page1: PageInfo = {
coId: "co_page1" as CoID,
name: "Page1",
};
const page2: PageInfo = {
coId: "co_page2" as CoID,
name: "Page2",
};
act(() => {
result.current.addPages([page1]);
});
expect(result.current.path).toEqual([page1]);
act(() => {
result.current.addPages([page2]);
});
expect(result.current.path).toEqual([page1, page2]);
act(() => {
result.current.goBack();
});
expect(result.current.path).toEqual([page1]);
const hash = window.location.hash.slice(2);
const decoded = hash.split("/").map((segment) => {
const [coId, encodedName] = segment.split(":");
return {
coId,
name: encodedName ? decodeURIComponent(encodedName) : undefined,
} as PageInfo;
});
expect(decoded).toEqual([page1]);
});
});
});