Skip to content

Instantly share code, notes, and snippets.

@andyjessop
Created February 17, 2025 06:14
Show Gist options
  • Save andyjessop/006cf7fbedfd9692ccd7c3b33c253266 to your computer and use it in GitHub Desktop.
Save andyjessop/006cf7fbedfd9692ccd7c3b33c253266 to your computer and use it in GitHub Desktop.
Router.ts
type RouteParams = Record<string, string>;
type SearchParams = Record<string, string>;
interface RouteDefinition {
path: string;
}
export type RouterConfig = Record<string, RouteDefinition>;
export interface FullRoute<T extends RouterConfig> {
name: keyof T & string;
params: RouteParams;
search: SearchParams;
hash: string;
}
export type Route<T extends RouterConfig = any> = Partial<FullRoute<T>> & {
name: keyof T & string;
};
export class Router<T extends RouterConfig> {
#config: T & { notFound: RouteDefinition };
#currentRoute: FullRoute<T>;
#onNavigateCallback?: (route: FullRoute<T>) => void;
constructor(initial: T, onNavigate?: (route: FullRoute<T>) => void) {
validateRouterConfig(initial);
this.#config = { notFound: { path: "/404" }, ...initial };
this.#currentRoute = this.#getRouteFromUrl(window.location.href) ?? {
hash: "",
name: "notFound" as keyof T & string,
params: {},
search: {},
};
this.#onNavigateCallback = onNavigate;
addEventListener("popstate", this.#onPopstate);
this.#onPopstate();
}
get route() {
return this.#currentRoute;
}
destroy() {
removeEventListener("popstate", this.#onPopstate);
}
navigate = (route: Route<T> | null): void => {
if (!route) {
this.navigate({ name: "notFound" as keyof T & string });
return;
}
const { hash, name, search, params } = route;
const url = this.#getUrlFromRoute(name, params, search, hash);
window.history.pushState({}, "", url);
this.#currentRoute = {
hash: hash ?? "",
name,
params: params ?? {},
search: search ?? {},
};
this.#onNavigateCallback?.(this.#currentRoute);
};
#onPopstate = () => {
const route = this.#getRouteFromUrl(window.location.href);
if (
window.location.hash.length === 0 &&
window.location.href.endsWith("#")
) {
// Remove the disgusting hash from the URL without reloading the page
// @ts-ignore
window.history.replaceState(null, null, window.location.pathname);
}
if (route) {
this.#currentRoute = route;
this.#onNavigateCallback?.(this.#currentRoute);
}
};
#getUrlFromRoute(
name: keyof T & string,
params?: RouteParams,
search?: SearchParams,
hash?: string,
): string {
let pathname = this.#config[name].path;
if (!pathname) {
throw new Error(`Route "${String(name)}" not found in config`);
}
if (pathname.includes(":") && !params) {
throw new Error(
`Route "${String(name)}" requires params but none were provided`,
);
}
for (const [key, value] of Object.entries(params || {})) {
pathname = pathname.replace(`:${key}`, value);
}
const searchStr = search
? Object.entries(search)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&")
: "";
return `${window.location.origin}${pathname}${searchStr ? `?${searchStr}` : ""}${
hash ? `#${hash}` : ""
}`;
}
#getRouteFromUrl(fullUrl: string): FullRoute<T> | null {
const { hash, pathname, searchParams } = new URL(fullUrl);
const params: RouteParams = {};
const normalisedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const entry = Object.entries(this.#config).find(([_, route]) => {
const routeTokens = route.path.split("/");
const pathTokens = normalisedPathname.split("/");
if (routeTokens.length !== pathTokens.length) return false;
return routeTokens.every((token, index) => {
if (token.startsWith(":")) {
params[token.slice(1)] = pathTokens[index];
return true;
}
return token === pathTokens[index];
});
});
if (!entry) return null;
const [name] = entry;
return {
hash: hash.slice(1),
name: name as keyof T & string,
params,
search: Object.fromEntries(searchParams.entries()),
};
}
}
/**
* Validates that all route paths in the provided configuration start with '/'.
* Throws an error if any route is found that does not conform to this requirement.
*/
function validateRouterConfig<T extends RouterConfig>(config: T): void {
for (const [routeName, routeDef] of Object.entries(config)) {
if (!routeDef.path.startsWith("/")) {
throw new Error(
`Route "${routeName}" has a path "${routeDef.path}" that does not start with "/".`,
);
}
}
}
import {
type ComponentType,
type LazyExoticComponent,
type ReactElement,
Suspense,
} from "react";
interface ComponentRoute {
component: LazyExoticComponent<ComponentType<any>>;
parent?: string;
path: string;
}
export interface ComponentRoutes {
[key: string]: ComponentRoute;
}
export function createRenderRoute(routes: ComponentRoutes) {
/**
* Render the component tree for a given route.
*/
return function RenderRoute(routeKey?: string): ReactElement | null {
if (!routeKey) {
return null;
}
const routeChain = findRouteChain(routeKey, routes);
if (routeChain.length === 0) {
return null;
}
return routeChain.reduceRight(
(children, currentKey) => {
const Component = routes[currentKey].component;
return (
<Suspense key={currentKey}>
<Component key={currentKey}>{children}</Component>
</Suspense>
);
},
null as ReactElement | null,
);
};
function findRouteChain(routeKey: string, routes: ComponentRoutes): string[] {
const chain: string[] = [];
let currentKey: string | undefined = routeKey;
while (currentKey) {
chain.unshift(currentKey);
currentKey = routes[currentKey].parent;
}
return chain;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment