"use client";
import { match } from "../lib/route-match.mjs";
const clientRoutes = new Map();
const serverRoutes = new Map();
const clientFallbackRoutes = new Map(); // path -> { component } (path = "/user/*" or "*")
const routeResources = new Map(); // path -> [{ resource, mapFn }]
export function registerClientRoute(
path,
{ exact, component, fallback, remote = false, outlet = null }
) {
if (fallback) {
const key = path || "*"; // global fallback uses "*"
clientFallbackRoutes.set(key, { component, remote, outlet });
return () => {
clientFallbackRoutes.delete(key);
};
}
clientRoutes.set(path, { exact, component, remote, outlet });
return () => clientRoutes.delete(path);
}
export function registerServerRoute(
path,
{ exact, fallback = false, hasLoading = false, remote = false, outlet = null }
) {
serverRoutes.set(path, { exact, fallback, hasLoading, remote, outlet });
return () => serverRoutes.delete(path);
}
export function matchClientRoute(pathname) {
// 1. Try regular routes first
for (const [path, route] of clientRoutes) {
const params = match(path, pathname, { exact: route.exact });
if (params) {
return { ...route, params, path };
}
}
// 2. Try scoped fallbacks (most specific prefix first)
const scopedFallbacks = [...clientFallbackRoutes.entries()]
.filter(([key]) => key !== "*")
.toSorted((a, b) => b[0].length - a[0].length);
for (const [pattern, route] of scopedFallbacks) {
const params = match(pattern, pathname);
if (params) return { ...route, params, path: pattern, fallback: true };
}
// 3. Global fallback
const globalFallback = clientFallbackRoutes.get("*");
if (globalFallback) {
return { ...globalFallback, params: {}, path: null, fallback: true };
}
return null;
}
/**
* Check if a fallback route should be active for the given pathname.
*
* A fallback is active when:
* 1. No regular (non-fallback) route matches the pathname
* 2. No more-specific scoped fallback already covers the pathname
* (e.g. "/user/*" beats "*" for paths under /user/)
*
* @param {string} pathname - The current pathname
* @param {string|undefined} fallbackPath - The caller's fallback pattern (e.g. "/user/*" or undefined for global)
*/
export function isFallbackActive(pathname, fallbackPath) {
// If any regular route matches, no fallback is active
for (const [path, route] of clientRoutes) {
if (match(path, pathname, { exact: route.exact })) return false;
}
for (const [path, route] of serverRoutes) {
// Skip fallback server routes (global or scoped)
if (!path || route.fallback) continue;
if (match(path, pathname, { exact: route.exact })) return false;
}
// Check if a more-specific scoped fallback already covers this pathname.
// A scoped fallback is "more specific" if it matches and has a longer
// pattern than the caller's fallback.
const callerKey = fallbackPath || "*";
for (const [key] of clientFallbackRoutes) {
if (key === callerKey || key === "*") continue;
// A different scoped fallback with a longer (more specific) prefix matches
if (key.length > callerKey.length && match(key, pathname)) return false;
}
return true;
}
/**
* Determine if a navigation can be handled entirely on the client.
* This is true when:
* 1. At least one client route matches the target pathname
* 2. No server route becomes newly active (stops matching is fine,
* ClientRouteGuard hides it via Activity)
*/
export function canNavigateClientOnly(fromPathname, toPathname) {
// Must have a client route that handles the target
if (!matchClientRoute(toPathname)) return false;
// If a server route becomes newly active at the target, we need RSC
// to render its content. Server routes that stop matching are fine —
// ClientRouteGuard hides them with <Activity mode="hidden">.
// Also, if a server route matches both but with different params,
// we need RSC to re-render with the new params.
for (const [path, route] of serverRoutes) {
// Fallback server routes (global or scoped) are always active;
// they never "become newly active", so skip them.
if (!path || route.fallback) continue;
const matchBefore = match(path, fromPathname, { exact: route.exact });
const matchAfter = match(path, toPathname, { exact: route.exact });
if (!matchBefore && matchAfter) return false;
// Same route, different params — server component needs re-render
if (matchBefore && matchAfter) {
const keysBefore = Object.keys(matchBefore);
for (const k of keysBefore) {
if (String(matchBefore[k]) !== String(matchAfter[k])) return false;
}
}
}
return true;
}
export function getClientRoutes() {
return clientRoutes;
}
export function getAllRoutes() {
const routes = [];
for (const [path, route] of serverRoutes) {
routes.push({
path: path || "/",
type: route.fallback ? "fallback" : "server",
exact: route.exact,
hasLoading: route.hasLoading,
remote: route.remote || false,
outlet: route.outlet || null,
});
}
for (const [path, route] of clientRoutes) {
routes.push({
path,
type: "client",
exact: route.exact,
remote: route.remote || false,
outlet: route.outlet || null,
});
}
for (const [key, route] of clientFallbackRoutes) {
routes.push({
path: key === "*" ? "*" : key,
type: "fallback",
remote: route.remote || false,
outlet: route.outlet || null,
});
}
return routes;
}
/**
* Register resource bindings for a route.
* Called from client code to enable resource loading on client-only navigation.
*
* @param {string} path - Route path pattern (e.g. "/todos")
* @param {Array} resources - Resource bindings: { resource, mapFn } or bare descriptors
* @returns {Function} Cleanup function
*/
export function registerRouteResources(path, resources) {
routeResources.set(path, resources);
return () => routeResources.delete(path);
}
/**
* Load all resources for a matched route.
* Called by the navigation system during client-only navigation.
*
* @param {string} pathname - Target pathname
* @param {string} [search] - Target search string (e.g. "?filter=active")
* @returns {Promise|null} Promise that resolves when all resources are loaded, or null if no resources
*/
export function loadRouteResources(pathname, search) {
// Find matching route resources
for (const [path, resources] of routeResources) {
const params = match(path, pathname, { exact: true });
if (!params) continue;
const searchParams = Object.fromEntries(new URLSearchParams(search || ""));
const loaders = [];
for (const binding of resources) {
if (binding.resource && binding.mapFn) {
if (!binding.resource._loader) continue;
const key = binding.mapFn({ params, search: searchParams });
loaders.push(binding.resource.query(key));
} else if (binding._loader && typeof binding.query === "function") {