alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
403 lines (304 loc) • 11.6 kB
text/typescript
import { $atom, Alepha, z } from "alepha";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ReactBrowserProvider,
type ReactHydrationState,
type RouterPushOptions,
reactBrowserOptions,
} from "../providers/ReactBrowserProvider.ts";
class TestReactBrowserProvider extends ReactBrowserProvider {
public testGetHydrationState = this.getHydrationState.bind(this);
public testAttachAnchorInterceptor = this.attachAnchorInterceptor.bind(this);
public testApplyHydration = this.applyHydration.bind(this);
public pushCalls: Array<{ url: string; options?: RouterPushOptions }> = [];
public override async push(
url: string,
options?: RouterPushOptions,
): Promise<void> {
this.pushCalls.push({ url, options });
}
}
describe("ReactBrowserProvider", () => {
let alepha: Alepha;
let provider: TestReactBrowserProvider;
beforeEach(() => {
alepha = Alepha.create();
provider = alepha.inject(TestReactBrowserProvider);
// Clean up any leftover script tags
document.getElementById("__ssr")?.remove();
});
describe("getHydrationState", () => {
it("should parse hydration data from script tag", () => {
const data = {
"alepha.react.router.layers": [
{ name: "home", props: { title: "Hello" } },
],
};
const script = document.createElement("script");
script.id = "__ssr";
script.type = "application/json";
script.textContent = JSON.stringify(data);
document.body.appendChild(script);
const result = provider.testGetHydrationState();
expect(result).toEqual(data);
expect(result?.["alepha.react.router.layers"]).toHaveLength(1);
expect(result?.["alepha.react.router.layers"]?.[0].name).toBe("home");
});
it("should return undefined when script tag is missing", () => {
const result = provider.testGetHydrationState();
expect(result).toBeUndefined();
});
it("should return undefined when script tag is empty", () => {
const script = document.createElement("script");
script.id = "__ssr";
script.type = "application/json";
script.textContent = "";
document.body.appendChild(script);
const result = provider.testGetHydrationState();
expect(result).toBeUndefined();
});
it("should return undefined and log error on malformed JSON", () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const script = document.createElement("script");
script.id = "__ssr";
script.type = "application/json";
script.textContent = "{invalid json";
document.body.appendChild(script);
const result = provider.testGetHydrationState();
expect(result).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledOnce();
consoleSpy.mockRestore();
});
it("should handle hydration data with multiple layers and atoms", () => {
const data = {
"alepha.react.router.layers": [
{ name: "layout", part: "/", config: { params: {} } },
{
name: "dashboard",
part: "/dashboard",
props: { count: 42 },
config: { params: {}, query: { tab: "overview" } },
},
],
"alepha.i18n.locale": "en",
};
const script = document.createElement("script");
script.id = "__ssr";
script.type = "application/json";
script.textContent = JSON.stringify(data);
document.body.appendChild(script);
const result = provider.testGetHydrationState();
expect(result?.["alepha.react.router.layers"]).toHaveLength(2);
expect(result?.["alepha.i18n.locale"]).toBe("en");
});
});
describe("applyHydration", () => {
const testAtom = $atom({
name: "test.hydration.settings",
schema: z.object({ theme: z.string() }),
default: { theme: "light" },
});
it("writes a valid hydrated value through, parsed against the schema", () => {
alepha.store.get(testAtom); // pre-register, as a real app would
provider.testApplyHydration({
[testAtom.key]: { theme: "dark", junk: true },
} as unknown as ReactHydrationState);
expect(alepha.store.get(testAtom)).toEqual({ theme: "dark" });
});
it("keeps the default and warns when a hydrated value fails schema validation", async () => {
alepha.store.get(testAtom); // pre-register, as a real app would
const onLog = vi.fn();
alepha.events.on("log", onLog);
provider.testApplyHydration({
[testAtom.key]: { theme: 42 },
} as unknown as ReactHydrationState);
// Logger delivery is event-based and async — flush before asserting.
await new Promise((r) => setTimeout(r, 0));
expect(alepha.store.get(testAtom)).toEqual({ theme: "light" });
expect(
onLog.mock.calls.some(([payload]) =>
payload.entry?.message?.includes(testAtom.key),
),
).toBe(true);
});
it("passes through a not-yet-registered atom value, decoded later at registration", () => {
provider.testApplyHydration({
[testAtom.key]: { theme: "dark", junk: true },
} as unknown as ReactHydrationState);
// First access registers the atom and decodes the raw hydrated value
// (StateManager.register()'s decode-at-registration path).
expect(alepha.store.get(testAtom)).toEqual({ theme: "dark" });
});
it("skips alepha.react.router.layers — it is not treated as an atom value", () => {
provider.testApplyHydration({
"alepha.react.router.layers": [{ name: "home" }],
} as ReactHydrationState);
expect(
alepha.store.getAtom("alepha.react.router.layers"),
).toBeUndefined();
});
});
describe("attachAnchorInterceptor", () => {
let alepha: Alepha;
let provider: TestReactBrowserProvider;
let detach: () => void;
let container: HTMLDivElement;
const createAnchor = (
attrs: Record<string, string>,
inner?: HTMLElement,
): HTMLAnchorElement => {
const a = document.createElement("a");
for (const [k, v] of Object.entries(attrs)) {
a.setAttribute(k, v);
}
if (inner) {
a.appendChild(inner);
} else {
a.textContent = "link";
}
container.appendChild(a);
return a;
};
const click = (
target: HTMLElement,
init: MouseEventInit = {},
): MouseEvent => {
const ev = new MouseEvent("click", {
bubbles: true,
cancelable: true,
button: 0,
...init,
});
target.dispatchEvent(ev);
return ev;
};
beforeEach(() => {
alepha = Alepha.create();
provider = alepha.inject(TestReactBrowserProvider);
container = document.createElement("div");
document.body.appendChild(container);
detach = provider.testAttachAnchorInterceptor();
});
afterEach(() => {
detach();
container.remove();
});
it("intercepts plain internal /foo anchor clicks", () => {
const a = createAnchor({ href: "/foo" });
const ev = click(a);
expect(provider.pushCalls).toHaveLength(1);
expect(provider.pushCalls[0].url).toBe("/foo");
expect(ev.defaultPrevented).toBe(true);
});
it("preserves query and hash when intercepting", () => {
const a = createAnchor({ href: "/foo?x=1#bar" });
click(a);
expect(provider.pushCalls[0].url).toBe("/foo?x=1#bar");
});
it("ignores cmd-click (metaKey)", () => {
const a = createAnchor({ href: "/foo" });
const ev = click(a, { metaKey: true });
expect(provider.pushCalls).toHaveLength(0);
expect(ev.defaultPrevented).toBe(false);
});
it("ignores ctrl-click", () => {
const a = createAnchor({ href: "/foo" });
click(a, { ctrlKey: true });
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores shift-click", () => {
const a = createAnchor({ href: "/foo" });
click(a, { shiftKey: true });
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores alt-click", () => {
const a = createAnchor({ href: "/foo" });
click(a, { altKey: true });
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores non-primary mouse buttons", () => {
const a = createAnchor({ href: "/foo" });
click(a, { button: 1 });
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores anchors with target='_blank'", () => {
const a = createAnchor({ href: "/foo", target: "_blank" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores anchors with download attribute", () => {
const a = createAnchor({ href: "/foo", download: "" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores anchors with data-no-router attribute", () => {
const a = createAnchor({ href: "/foo", "data-no-router": "" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores mailto: hrefs", () => {
const a = createAnchor({ href: "mailto:foo@bar.com" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores tel: hrefs", () => {
const a = createAnchor({ href: "tel:+15555555" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores hrefs to external origins", () => {
const a = createAnchor({ href: "https://example.com/foo" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("ignores hash-only #section hrefs", () => {
const a = createAnchor({ href: "#section" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("intercepts when click target is nested inside the anchor", () => {
const span = document.createElement("span");
span.textContent = "inner";
createAnchor({ href: "/foo" }, span);
click(span);
expect(provider.pushCalls).toHaveLength(1);
expect(provider.pushCalls[0].url).toBe("/foo");
});
it("skips when defaultPrevented is already true", () => {
const a = createAnchor({ href: "/foo" });
a.addEventListener("click", (ev) => ev.preventDefault());
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
it("respects interceptAnchorClicks=false at runtime", () => {
alepha.store.set(reactBrowserOptions.key, {
...alepha.store.get(reactBrowserOptions.key)!,
interceptAnchorClicks: false,
});
const a = createAnchor({ href: "/foo" });
click(a);
expect(provider.pushCalls).toHaveLength(0);
});
});
/**
* `url` is what `push()` compares the committed route against and what gets
* written back to history. Dropping the fragment here made every internal
* `<a href="/docs#section">` land on /docs with no anchor, never scrolling.
*/
describe("url", () => {
it("keeps the hash fragment", () => {
window.history.pushState({}, "", "/docs#section");
expect(provider.url).toBe("/docs#section");
});
it("keeps the hash alongside the query string", () => {
window.history.pushState({}, "", "/docs?q=1#section");
expect(provider.url).toBe("/docs?q=1#section");
});
it("is unchanged when there is no hash", () => {
window.history.pushState({}, "", "/docs?q=1");
expect(provider.url).toBe("/docs?q=1");
});
});
});