/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import {createBrowserHistory} from './history';
import {
type Match,
type NavigateOptions,
NotFound,
Redirect,
type Route,
type RouteContext,
type Router,
type RouterOptions,
type RouterState,
type ScrollBehavior,
type To,
} from './types';
export class RouterImpl implements Router {
private readonly routes: Array;
private readonly routeById: Map;
private readonly history;
private readonly options: RouterOptions;
private state: RouterState;
private listeners = new Set<() => void>();
private unsubscribeHistory?: () => void;
private preloadCache = new Map | 'done'>();
constructor(options: RouterOptions) {
this.options = options;
this.history = options.history ?? createBrowserHistory();
this.routes = options.routes.map((cfg) => ({
...cfg,
pattern:
cfg.pattern ?? (cfg.path != null ? new URLPattern({pathname: cfg.path}) : new URLPattern({pathname: '*'})),
}));
this.routeById = new Map(this.routes.map((r) => [r.id, r]));
const loc = this.history.getLocation();
const normalizedInitialUrl = this.normalizeUrl(loc.url);
if (normalizedInitialUrl.toString() !== loc.url.toString()) {
this.history.replace(normalizedInitialUrl, loc.state);
}
const matches = this.matchUrl(normalizedInitialUrl);
this.state = {
location: normalizedInitialUrl,
matches,
navigating: false,
pending: null,
error: undefined,
historyState: loc.state,
};
this.unsubscribeHistory = this.history.listen((location) => {
void this.handlePop(location.url, location.state);
});
this.runInitialGuards();
}
getState(): RouterState {
return this.state;
}
getRoutes(): Array {
return this.routes;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
destroy(): void {
if (this.unsubscribeHistory) {
this.unsubscribeHistory();
this.unsubscribeHistory = undefined;
}
this.listeners.clear();
}
private notify() {
for (const l of this.listeners) l();
}
private runInitialGuards(): void {
const {location, historyState} = this.state;
void this.transitionTo(location, {
replace: true,
scroll: 'preserve',
historyState,
isPop: true,
forceEnter: true,
}).catch((err) => {
if (typeof console !== 'undefined') {
console.error('[router] initial guard check failed', err);
}
});
}
private normalizeUrl(url: URL): URL {
if (url.pathname.length <= 1 || !url.pathname.endsWith('/')) {
return url;
}
const normalized = new URL(url.toString());
const trimmedPath = url.pathname.replace(/\/+$/, '') || '/';
normalized.pathname = trimmedPath;
return normalized;
}
resolveTo(to: To, from?: URL): URL {
const base = from ?? this.state.location ?? new URL(window.location.href);
if (typeof to === 'string') {
return this.normalizeUrl(new URL(to, base));
}
const url = new URL(to.to, base);
if (to.search) {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(to.search)) {
if (v === undefined) continue;
if (v === null) {
sp.set(k, '');
continue;
}
sp.set(k, String(v));
}
const s = sp.toString();
url.search = s ? `?${s}` : '';
}
if (to.hash) {
url.hash = to.hash.startsWith('#') ? to.hash : `#${to.hash}`;
}
return this.normalizeUrl(url);
}
async navigate(to: To, opts: NavigateOptions = {}): Promise {
const from = this.state.location;
const targetUrl = this.resolveTo(to, from);
let historyState =
opts.state ?? (typeof to === 'object' && 'state' in to ? to.state : undefined) ?? this.state.historyState;
if (historyState === undefined) historyState = null;
const pendingMatches = this.matchUrl(targetUrl);
this.state = {
...this.state,
navigating: true,
pending: pendingMatches,
error: undefined,
historyState,
};
this.notify();
await this.transitionTo(targetUrl, {
replace: opts.replace,
scroll: opts.scroll ?? this.options.scrollRestoration ?? 'top',
historyState,
isPop: false,
});
}
async preload(to: To): Promise {
const url = this.resolveTo(to, this.state.location);
const matches = this.matchUrl(url);
if (!matches.length) return;
const leaf = matches[matches.length - 1];
if (!leaf.route.preload) return;
const key = this.preloadKey(leaf.route, url);
const existing = this.preloadCache.get(key);
if (existing) {
if (existing === 'done') return;
await existing;
return;
}
const ctx: RouteContext = {
url,
params: leaf.params,
search: leaf.search,
state: this.state.historyState,
route: leaf.route,
matches,
router: this,
};
const promise = Promise.resolve(leaf.route.preload(ctx)).then(
() => {
this.preloadCache.set(key, 'done');
},
(err) => {
this.preloadCache.delete(key);
if (typeof console !== 'undefined') {
console.error('[router] preload failed', err);
}
},
);
this.preloadCache.set(key, promise);
await promise;
}
private preloadKey(route: Route, url: URL): string {
return `${route.id}|${url.pathname}|${url.search}`;
}
private async handlePop(url: URL, state: unknown): Promise {
const normalizedUrl = this.normalizeUrl(url);
if (normalizedUrl.toString() !== url.toString()) {
this.history.replace(normalizedUrl, state);
return;
}
await this.transitionTo(normalizedUrl, {
isPop: true,
historyState: state,
scroll: 'preserve',
});
}
private matchUrl(url: URL): Array {
const routesWithPaths = this.routes
.filter((r) => r.path != null)
.sort((a, b) => {
const aSegments = (a.path ?? '').split('/').filter(Boolean);
const bSegments = (b.path ?? '').split('/').filter(Boolean);
if (bSegments.length !== aSegments.length) {
return bSegments.length - aSegments.length;
}
const aWildcards = aSegments.filter((s) => s.startsWith(':') || s === '*').length;
const bWildcards = bSegments.filter((s) => s.startsWith(':') || s === '*').length;
if (aWildcards !== bWildcards) {
return aWildcards - bWildcards;
}
if (a.parentId && !b.parentId) return -1;
if (!a.parentId && b.parentId) return 1;
return 0;
});
for (const route of routesWithPaths) {
const exec = route.pattern.exec(url);
if (!exec) continue;
const search = url.searchParams;
const chain: Array = [];
let current: Route | undefined = route;
while (current) {
const currentExec =
current === route ? exec : (current.pattern.exec(url) ?? ({pathname: {groups: {}}} as URLPatternResult));
const params = (currentExec.pathname.groups ?? {}) as Record;
chain.push({
route: current,
params,
search,
});
if (!current.parentId) break;
current = this.routeById.get(current.parentId);
}
chain.reverse();
return chain;
}
const notFoundRouteId = this.options.notFoundRouteId;
if (notFoundRouteId) {
const nf = this.routeById.get(notFoundRouteId);
if (nf) {
return [
{
route: nf,
params: {},
search: url.searchParams,
},
];
}
}
return [];
}
private async transitionTo(
url: URL,
opts: {
replace?: boolean;
scroll?: ScrollBehavior;
historyState?: unknown;
isPop?: boolean;
forceEnter?: boolean;
},
): Promise {
const prevState = this.state;
const prevMatches = opts.forceEnter ? [] : prevState.matches;
const nextMatches = this.matchUrl(url);
const historyState = opts.historyState ?? prevState.historyState ?? null;
const mkCtx = (match: Match): RouteContext => ({
url,
params: match.params,
search: match.search,
state: historyState,
route: match.route,
matches: nextMatches,
router: this,
});
const firstDifferentIndex =
opts.forceEnter === true
? 0
: (() => {
let i = 0;
while (
i < prevMatches.length &&
i < nextMatches.length &&
prevMatches[i].route.id === nextMatches[i].route.id
) {
i++;
}
return i;
})();
try {
for (let i = firstDifferentIndex; i < nextMatches.length; i++) {
const match = nextMatches[i];
const route = match.route;
if (route.onEnter) {
const res = route.onEnter(mkCtx(match));
if (res instanceof Redirect) {
const redirectUrl = this.resolveTo(res.to, url);
await this.transitionTo(redirectUrl, {
replace: res.replace ?? true,
scroll: opts.scroll,
historyState,
isPop: false,
});
return;
}
if (res instanceof NotFound) {
await this.handleNotFound(url, historyState);
return;
}
}
}
} catch (err) {
if (err instanceof Redirect) {
const redirectUrl = this.resolveTo(err.to, url);
await this.transitionTo(redirectUrl, {
replace: err.replace ?? true,
scroll: opts.scroll,
historyState,
isPop: false,
});
return;
}
if (err instanceof NotFound) {
await this.handleNotFound(url, historyState);
return;
}
this.state = {
...prevState,
navigating: false,
pending: null,
error: err,
historyState,
};
this.notify();
throw err;
}
for (let i = prevMatches.length - 1; i >= firstDifferentIndex; i--) {
const prevMatch = prevMatches[i];
const route = prevMatch.route;
if (route.onLeave) {
route.onLeave({
url: prevState.location,
params: prevMatch.params,
search: prevState.location.searchParams,
state: prevState.historyState,
route,
matches: prevMatches,
router: this,
});
}
}
const isSamePath =
prevState.location.pathname === url.pathname &&
prevState.location.search === url.search &&
prevState.location.hash === url.hash;
if (!opts.isPop) {
if (opts.replace || isSamePath) {
this.history.replace(url, historyState);
} else {
this.history.push(url, historyState);
}
}
this.state = {
location: url,
matches: nextMatches,
navigating: false,
pending: null,
error: undefined,
historyState,
};
this.notify();
const behavior = opts.scroll ?? this.options.scrollRestoration ?? 'top';
queueMicrotask(() => {
if (behavior === 'preserve') return;
if (url.hash) {
const id = url.hash.slice(1);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView();
return;
}
}
if (behavior === 'top') {
window.scrollTo({top: 0, left: 0});
}
});
}
private async handleNotFound(url: URL, historyState: unknown): Promise {
const notFoundRouteId = this.options.notFoundRouteId;
if (!notFoundRouteId) {
this.state = {
...this.state,
location: url,
matches: [],
navigating: false,
pending: null,
error: new NotFound(),
historyState,
};
this.notify();
return;
}
const nf = this.routeById.get(notFoundRouteId);
if (!nf) {
this.state = {
...this.state,
location: url,
matches: [],
navigating: false,
pending: null,
error: new NotFound(),
historyState,
};
this.notify();
return;
}
const match: Match = {
route: nf,
params: {},
search: url.searchParams,
};
this.state = {
location: url,
matches: [match],
navigating: false,
pending: null,
error: undefined,
historyState,
};
this.notify();
}
}
export function createRouter(options: RouterOptions): Router {
return new RouterImpl(options);
}