@frontity/tiny-router
Version:
A tiny router for Frontity projects
323 lines (291 loc) • 11.7 kB
text/typescript
import TinyRouter, { Packages } from "../types";
import { warn, error, observe, batch } from "frontity";
import { isError, isRedirection } from "@frontity/source";
import { Derived } from "frontity/types";
import { Data } from "@frontity/source/types";
/**
* This is an experimental function to be able to resolve the types of derived
* state (Derived type) and Actions (Action type). It is not complete and only
* works for this case, but it is something that if proven useful could be
* exposed in "frontity/types". It is based on some tips of this talk:
* https://www.youtube.com/watch?v=wNsKJMSqtAk.
*
* @param derivedOrAction - The definition of the action.
* @returns The same value in JavaScript, but the resolved value in TypeScript.
*/
const resolved = <T extends (...args: any) => any>(
derivedOrAction: T
): ReturnType<T> => derivedOrAction as any;
/**
* Set the URL.
*
* @param link - The URL that will replace the current one. It can be a path
* like `/category/nature/`, a path that includes the page
* `/category/nature/page/2` or the full URL `https://site.com/category/nature`.
*
* @param options - An optional configuration object that can contain:
* - `method` "push" | "replace" (default: "push").
*
* The method used in the action. "push" corresponds to window.history.pushState
* and "replace" to window.history.replaceState.
*
* - `state` - An object that will be saved in window.history.state. This object
* is recovered when the user go back and forward using the browser buttons.
*
* @example
* ```
* const Link = ({ actions, children, link }) => {
* const onClick = (event) => {
* event.preventDefault();
* actions.router.set(link);
* };
*
* return (
* <a href={link} onClick={onClick}>
* {children}
* </a>
* );
* };
* ```
* @returns Void.
*/
export const set: TinyRouter["actions"]["router"]["set"] =
({ state, actions, libraries }) =>
(link, options = {}): void => {
// Normalize the link.
if (libraries.source && libraries.source.normalize)
link = libraries.source.normalize(link);
// If the link hasn't changed, do nothing.
if (state.router.link === link) return;
// Clone the state that we are going to use for `window.history` because it
// cannot contain proxies.
const historyState = JSON.parse(JSON.stringify(options.state || {}));
// If the data is a redirection, then we set the link to the location.
// The redirections are stored in source.data just like any other data.
const data = state.source?.get(link);
if (data && data.isReady && isRedirection(data)) {
if (data.isExternal) {
window.replaceLocation(data.location);
} else {
// If the link is internal, we have to discard the domain.
const { pathname, search, hash } = new URL(
data.location,
"https://dummy-domain.com"
);
// If there is a link normalize, we have to use it.
if (libraries.source && libraries.source.normalize)
link = libraries.source.normalize(pathname + search + hash);
else link = pathname + search + hash;
}
}
// If we are in the client, update `window.history` and fetch the link.
if (state.frontity.platform === "client") {
if (!options.method || options.method === "push")
window.history.pushState(historyState, "", link);
else if (options.method === "replace")
window.history.replaceState(historyState, "", link);
else if (options.method !== "pop") {
// Throw an error if another method is used. We support "pop" internally
// for popstate events.
error(
`The method ${options.method} is not supported by actions.router.set.`
);
}
// If `autoFetch` is on, do the fetch.
if (state.router.autoFetch) actions.source?.fetch(link);
}
// Finally, set the `state.router.link` property to the new value.
batch(() => {
state.router.previous = state.router.link;
state.router.link = link;
state.router.state = historyState;
});
};
/**
* Replace the value of `state.router.state` with the give object.
*
* This implementation also executes a `window.history.replaceState()` with that
* object.
*
* @param historyState - The history state object.
* @returns Void.
*/
export const updateState: TinyRouter["actions"]["router"]["updateState"] =
({ state }) =>
(historyState: Record<string, unknown>) => {
// Clone the state to make sure we don't leak proxies.
const cloned = JSON.parse(JSON.stringify(historyState));
state.router.state = cloned;
window.history.replaceState(cloned, "");
};
/**
* Initilization of the router.
*
* @param store - The Frontity store.
*/
export const init: TinyRouter["actions"]["router"]["init"] = ({
state,
actions,
libraries,
}) => {
if (state.frontity.platform === "server") {
// Populate the router info with the initial path and page.
state.router.link = libraries.source?.normalize
? libraries.source.normalize(state.frontity.initialLink)
: state.frontity.initialLink;
} else {
// Wrap `window.replace.location` so we can mock it in the e2e tests.
// This is required because `window.location` is protected by the browser
// and can't be modified.
window.replaceLocation =
window.replaceLocation || window.location.replace.bind(window.location);
// Observe the current data object. If it is ever a redirection, replace the
// current link with the new one.
observe(() => {
const data = state.source?.get(state.router.link);
if (data && isRedirection(data)) {
// If the redirection is external, redirect to the full URL.
if (data.isExternal) {
window.replaceLocation(data.location);
} else {
// If the redirection is internal, use actions.router.set to switch
// to the new redirection.
actions.router.set(data.location, {
// Use "replace" to keep browser history consistent.
method: "replace",
// Keep the same history.state that the old link had. We have to
// stringfy and parse the object because window.history.replaceState()
// does not accept a Proxy.
state: state.router.state,
});
}
}
});
// The link stored in `state.router.link` may be wrong if the server changes
// it in some cases (see https://github.com/frontity/frontity/issues/623).
// For that reason, it is replaced with the current link in the browser.
// We should remove it once we have Frontity Hooks/Filters.
// Get the browser URL to remove the Frontity options.
const browserURL = new URL(location.href);
Array.from(browserURL.searchParams.keys()).forEach((key) => {
if (key.startsWith("frontity_")) browserURL.searchParams.delete(key);
});
// Get the browser link.
const browserLink =
browserURL.pathname + browserURL.search + browserURL.hash;
// Normalize it.
const link = libraries.source?.normalize
? libraries.source.normalize(browserLink)
: browserLink;
// Add the state to the browser history and replace the link.
window.history.replaceState(
JSON.parse(JSON.stringify(state.router.state)),
"",
link
);
// We have to compare the `initalLink` with `browserLink` because we have
// normalized the `link` at this point and `initialLink` is not normalized.
// We also need to sort it because some CDNs return the same response for
// URLs with same queries in different order.
// Finally, we need to skip this on HMR.
const browserLinkUrl = new URL(browserLink, "https://dummy.com");
browserLinkUrl.searchParams.sort();
const search = browserLinkUrl.searchParams.toString()
? `?${browserLinkUrl.searchParams.toString()}`
: "";
const sortedBrowserLink = `${browserLinkUrl.pathname}${search}${browserLinkUrl.hash}`;
// Also compare the normalized `initialLink` with the normalized
// `browserLink` to avoid creating an infinite loop.
const normalizedInitialLink = libraries.source?.normalize
? libraries.source.normalize(state.frontity.initialLink)
: state.frontity.initialLink;
if (
sortedBrowserLink !== state.frontity.initialLink &&
link !== normalizedInitialLink &&
!state.frontity.hmr
) {
if (state.source) {
/**
* Derived state pointing to the initial data object.
*
* @param store - The Frontity store.
* @returns The initial data object.
*/
const initialDataObject: Derived<Packages, Data> = ({ state }) =>
state.source.get(state.frontity.initialLink);
state.source.data[link] = resolved(initialDataObject);
}
// Update the value of `state.router.link`.
state.router.link = link;
}
// Listen to changes in history.
window.addEventListener("popstate", (event) => {
if (event.state) {
actions.router.set(
location.pathname + location.search + location.hash,
// We are casting types here because `pop` is used only internally,
// therefore we don't want to expose it in the types for users.
{ method: "pop", state: event.state } as any
);
}
});
}
};
/**
* Implementation of the `beforeSSR()` Frontity action as used by the
* tiny-router.
*
* @param ctx - The context of the Koa application.
*
* @returns Void.
*/
export const beforeSSR: TinyRouter["actions"]["router"]["beforeSSR"] =
({ state, actions }) =>
async ({ ctx }) => {
// If autoFetch is disabled, there is nothing to do.
if (!state.router.autoFetch) {
return;
}
// Because Frontity is a modular framework, it could happen that a source
// package like `@frontity/wp-source` has not been installed but the user is
// trying to use autoFetch option, which requires it.
if (!actions.source || !actions.source.fetch || !state.source.get) {
warn(
"You are trying to use autoFetch but no source package is installed."
);
return;
}
// Fetch the current link.
await actions.source.fetch(state.router.link);
const data = state.source.get(state.router.link);
// Check if the link has a redirection.
if (data && isRedirection(data)) {
// If the redirection is external, just redirect to the full URL here.
if (data.isExternal) {
ctx.status = data.redirectionStatus;
ctx.redirect(data.location);
return;
}
// Recover all the missing query params from the original URL. This is
// required because we remove the query params that start with `frontity_`.
const location = new URL(data.location, "https://dummy-domain.com");
ctx.URL.searchParams.forEach((value, key) => {
if (!location.searchParams.has(key))
location.searchParams.append(key, value);
});
// Set the correct status for the redirection. It could be a 301, 302, 307
// or 308.
ctx.status = data.redirectionStatus;
// 30X redirections can be relative so adding the Frontity URL is not
// needed - see https://tools.ietf.org/html/rfc7231#page-68.
const redirectionURL =
location.pathname + location.search + location.hash;
ctx.redirect(redirectionURL);
return;
}
if (isError(data)) {
// If there was an error, return the proper status.
ctx.status = data.errorStatus;
return;
}
};