@frontity/tiny-router
Version:
A tiny router for Frontity projects
461 lines (367 loc) • 14 kB
text/typescript
import * as frontity from "frontity";
import * as frontityError from "@frontity/error";
import { Context } from "frontity/types";
import tinyRouter from "..";
import { Packages } from "../../types";
import { SetOptions } from "@frontity/router/types";
import { RedirectionData } from "@frontity/source/types";
let config: any;
let normalize: jest.Mock;
let fetch: jest.Mock;
let get: jest.Mock;
const createStore = (config) => frontity.createStore<Packages>(config);
const spiedPushState = jest.spyOn(window.history, "pushState");
const spiedReplaceState = jest.spyOn(window.history, "replaceState");
beforeEach(() => {
normalize = jest.fn().mockImplementation((link) => {
const { pathname, search, hash } = new URL(link, "https://dummy.com");
return pathname + search + hash;
});
fetch = jest.fn();
get = jest.fn().mockReturnValue({ isReady: false, isFetching: false });
config = {
name: "@frontity/tiny-router",
state: {
frontity: {
platform: "client",
initialLink: "/initial/link/",
},
router: { ...tinyRouter.state.router },
source: {
get: () => get,
data: {},
},
},
actions: {
router: {
...tinyRouter.actions.router,
},
source: {
fetch: () => fetch,
},
},
libraries: {
source: {
normalize,
},
},
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe("actions", () => {
describe("set", () => {
it("should work just with links", () => {
const store = createStore(config);
const link = "/some-post/";
store.actions.router.set(link);
expect(normalize).toHaveBeenCalledWith(link);
expect(spiedPushState).toHaveBeenCalledTimes(1);
expect(store.state.router.link).toBe(link);
});
it("should work with full URLs", () => {
const store = createStore(config);
const link = "https://blog.example/some-post/page/3/?some=query";
store.actions.router.set(link);
expect(normalize).toHaveBeenCalledWith(link);
expect(store.state.router.link).toBe("/some-post/page/3/?some=query");
expect(spiedPushState).toHaveBeenCalledTimes(1);
});
it("should not create new history entry if link is the same", () => {
const store = createStore(config);
const link = "/some-post/";
store.actions.router.set(link);
expect(spiedPushState).toHaveBeenCalledTimes(1);
store.actions.router.set(link);
expect(spiedPushState).toHaveBeenCalledTimes(1);
});
it("should populate latest link, method and state", () => {
const store = createStore(config);
const link = "/some-post/page/3/?some=query";
const options: SetOptions = {
method: "replace",
state: {
initial: 1,
pages: [1, 2, 3],
},
};
store.actions.router.set(link, options);
expect(store.state.router.link).toBe(link);
expect(store.state.router.state).toEqual(options.state);
});
it("should populate previous link if current link and next link are different", () => {
const store = createStore(config);
const current = "/";
const next = "/page/2/";
store.state.router.link = current;
store.actions.router.set(next);
expect(store.state.router.link).toBe(next);
expect(store.state.router.previous).toBe(current);
});
it("should not populate previous link if current link and next link are the same", () => {
const store = createStore(config);
const current = "/page/2/";
const next = "/page/2/";
store.state.router.link = current;
store.actions.router.set(next);
expect(store.state.router.link).toBe(next);
expect(store.state.router.previous).toBeUndefined();
});
it("should follow the `options.method` in the client", () => {
const store = createStore(config);
store.state.frontity.platform = "client";
const link = "/some-post/";
const options: SetOptions = {
method: "push",
};
store.actions.router.set(link, options);
expect(spiedPushState).toHaveBeenCalledTimes(1);
const link2 = "/other-post/";
options.method = "replace";
store.actions.router.set(link2, options);
expect(spiedReplaceState).toHaveBeenCalledTimes(1);
});
it("should clone the history state and store it in `window.history`", () => {
const store = createStore(config);
store.state.frontity.platform = "client";
const link = "/some-post/";
const options: SetOptions = {
method: "push",
state: {
initial: 1,
pages: [1, 2, 3],
},
};
store.actions.router.set(link, options);
expect(store.state.router.state).toEqual(options.state);
expect(window.history.state).toEqual(options.state);
expect(window.history.state).not.toBe(options.state);
expect(window.history.state.pages).not.toBe(options.state.pages);
options.method = "replace";
store.actions.router.set(link, options);
expect(store.state.router.state).toEqual(options.state);
expect(window.history.state).toEqual(options.state);
expect(window.history.state).not.toBe(options.state);
expect(window.history.state.pages).not.toBe(options.state.pages);
});
it("should fetch if `autoFetch` is enabled", () => {
const store = createStore(config);
store.state.frontity.platform = "client";
let link = "/first-link/";
store.actions.router.set(link);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenLastCalledWith(link);
link = "/second-link/";
store.actions.router.set(link, { method: "replace" });
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenLastCalledWith(link);
});
it("should redirect to the final link if there is an internal redirection", () => {
const store = createStore(config);
get.mockReturnValue({
isReady: true,
isFetching: false,
link: "/initial-url/",
route: "/initial-url/",
page: 1,
query: {},
isRedirection: true,
isExternal: false,
location: "https://backend.com/final-url/",
});
store.actions.router.set("/initial-url");
expect(store.state.router.link).toBe("/final-url/");
});
it("should redirect to the final link if there is an external redirection", () => {
const store = createStore(config);
get.mockReturnValue({
isReady: true,
isFetching: false,
link: "/initial-url/",
route: "/initial-url/",
page: 1,
query: {},
isRedirection: true,
isExternal: true,
location: "https://external.com/final-url",
});
window.replaceLocation = jest.fn();
store.actions.router.set("/initial-url");
expect(window.replaceLocation).toHaveBeenCalledWith(
"https://external.com/final-url"
);
});
});
describe("updateState", () => {
test("should replace the current browser state with the new state", () => {
const store = createStore(config);
const currentState = {
links: ["/"],
};
const link = store.state.router.link;
store.state.router.state = currentState;
const nextState = {
links: ["/", "/page/2/"],
};
expect(store.state.router.link).toBe(link);
expect(store.state.router.state).toEqual(currentState);
store.actions.router.updateState(nextState);
expect(store.state.router.link).toBe(link);
expect(store.state.router.state).toEqual(nextState);
expect(spiedReplaceState).toHaveBeenCalledTimes(1);
expect(spiedReplaceState).toHaveBeenCalledWith(nextState, "");
});
});
describe("init", () => {
it("should populate the initial link", () => {
const store = createStore(config);
store.state.frontity.platform = "server";
store.actions.router.init();
// check that first state is correct
expect(normalize).toHaveBeenCalledTimes(1);
expect(normalize).toHaveBeenCalledWith("/initial/link/");
expect(store.state.router.link).toBe("/initial/link/");
});
it("should fire `replaceState` in the init to populate the history state", () => {
config.state.frontity.platform = "client";
const store = createStore(config);
store.state.router.state = { some: "state" };
store.actions.router.init();
expect(spiedReplaceState).toHaveBeenCalledTimes(1);
expect(window.history.state).toEqual(store.state.router.state);
});
it('should add event handler for "popstate" events', () => {
config.state.frontity.platform = "client";
const store = createStore(config);
store.actions.router.init();
const pathname = "/about-us/";
const search = "?id=3&search=value";
const hash = "#element";
const link = pathname + search + hash;
const oldLocation = window.location;
delete window.location;
(window.location as any) = { pathname, search, hash };
// Checks that there is an event listener handleling `popstate`.
window.dispatchEvent(
new PopStateEvent("popstate", { state: { some: "different state" } })
);
expect(store.state.router.link).toBe(link);
expect(store.state.router.state).toEqual({ some: "different state" });
expect(store.state.router.state).not.toBe({ some: "different state" });
window.location = oldLocation;
});
it("should trigger a new `action.router.set` if the current data object is an internal redirection", () => {
config.state.frontity.platform = "client";
const store = createStore(config);
get.mockImplementation((_) => store.state.source.data["/"]);
store.actions.router.init();
const redirection: RedirectionData = {
isReady: true,
isFetching: false,
link: "/initial-url/",
route: "/initial-url/",
page: 1,
query: {},
isRedirection: true,
redirectionStatus: 301,
isExternal: false,
location: "https://backend.com/final-url/",
};
store.state.source.data["/"] = redirection;
expect(store.state.router.link).toBe("/final-url/");
});
it("should do SSR if the current data object is an external redirection", () => {
config.state.frontity.platform = "client";
const store = createStore(config);
get.mockImplementation((_) => store.state.source.data["/"]);
window.replaceLocation = jest.fn();
store.actions.router.init();
const redirection: RedirectionData = {
isReady: true,
isFetching: false,
link: "/initial-url/",
route: "/initial-url/",
page: 1,
query: {},
isRedirection: true,
redirectionStatus: 301,
isExternal: true,
location: "https://external.com/final-url/",
};
store.state.source.data["/"] = redirection;
expect(window.replaceLocation).toHaveBeenCalledWith(
"https://external.com/final-url/"
);
});
});
describe("beforeSSR", () => {
it("should warn if autoFetch is enabled but there is no source pkg", () => {
const ctx = {} as Context;
get.mockReturnValue({});
const frontityWarn = jest.spyOn(frontityError, "warn");
const store = createStore(config);
store.actions.source = undefined;
store.actions.router.beforeSSR({ ctx });
expect(frontityWarn).toHaveBeenCalledTimes(1);
expect(frontityWarn).toHaveBeenCalledWith(
"You are trying to use autoFetch but no source package is installed."
);
});
it("should fetch if autoFetch is enabled", () => {
const ctx = {} as Context;
get.mockReturnValue({});
const store = createStore(config);
store.state.frontity.platform = "server";
store.libraries.source = undefined;
store.actions.router.init();
store.actions.router.beforeSSR({ ctx });
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("/initial/link/");
});
it("should change the context status if there is an error", async () => {
const ctx = {} as Context;
get.mockReturnValue({ isError: true, errorStatus: 123 });
const store = createStore(config);
await store.actions.router.beforeSSR({ ctx });
expect(ctx.status).toBe(123);
});
it("should change the context status if there is an internal redirection", async () => {
const ctx: Partial<Context> = {
URL: new URL("https://localhost/"),
redirect: jest.fn(),
};
get.mockReturnValue({
isReady: true,
isRedirection: true,
redirectionStatus: 123,
isExternal: false,
location: "https://backend.com/final-url/?query=value#hash",
});
const store = createStore(config);
store.state.frontity.url = "https://domain.com";
await store.actions.router.beforeSSR({ ctx: ctx as Context });
expect(ctx.redirect).toHaveBeenCalledWith("/final-url/?query=value#hash");
expect(ctx.status).toBe(123);
});
it("should change the context status if there is an external redirection", async () => {
const ctx: Partial<Context> = {
URL: new URL("https://localhost/"),
redirect: jest.fn(),
};
get.mockReturnValue({
isReady: true,
isRedirection: true,
redirectionStatus: 123,
isExternal: true,
location: "https://external.com/final-url/?query=value#hash",
});
const store = createStore(config);
await store.actions.router.beforeSSR({ ctx: ctx as Context });
expect(ctx.redirect).toHaveBeenCalledWith(
"https://external.com/final-url/?query=value#hash"
);
expect(ctx.status).toBe(123);
});
});
});