// Client-side route matcher for optimistic navigation.
//
// The SSR server owns the authoritative path→component mapping, but for
// optimistic navigation (painting a destination's Suspense fallback with a
// `` BEFORE the SSR fetch resolves) the client must resolve the
// destination route from the clicked href on its own. The build manifest ships
// each page route's `path` pattern (e.g. `/p/[slug]`) for exactly this — this
// module is the pure matcher over those patterns.
//
// Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so it has one
// source of truth and is unit-tested directly. The client bundler stages a copy
// next to the generated runtime, which imports it as `./route-match`.
export interface RouteMatch {
/** Route component path — the manifest key + __PYLON_DATA__.component value. */
component: string;
/** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
params: Record;
}
/** The slice of the build manifest this module needs. */
export interface MatchableManifest {
routes: Record<
string,
{
path?: string;
/** The route's own entry chunk (outdir-relative). */
file?: string;
/** Shared chunks the browser needs before that entry runs. */
imports?: string[];
}
>;
}
function splitPath(p: string): string[] {
const trimmed = p.replace(/\/+$/, "");
return trimmed === "" ? [] : trimmed.slice(1).split("/");
}
interface SegMatch {
params: Record;
/** Count of `[param]` segments — lower is more specific. */
dynamic: number;
/** Count of `[...rest]` catch-alls — dominates specificity. */
catchAll: number;
}
// Match a route pattern's segments against a concrete path's segments. Handles
// static segments, `[param]` (single segment), and `[...param]` (catch-all,
// consumes the remainder). Returns null on any mismatch.
function matchSegments(
patSegs: string[],
targetSegs: string[],
): SegMatch | null {
const params: Record = {};
let dynamic = 0;
for (let i = 0; i < patSegs.length; i++) {
const seg = patSegs[i];
if (seg.startsWith("[...") && seg.endsWith("]")) {
const name = seg.slice(4, -1);
params[name] = targetSegs
.slice(i)
.map(safeDecode)
.join("/");
return { params, dynamic, catchAll: 1 };
}
if (i >= targetSegs.length) return null;
if (seg.startsWith("[") && seg.endsWith("]")) {
params[seg.slice(1, -1)] = safeDecode(targetSegs[i]);
dynamic++;
} else if (seg !== targetSegs[i]) {
return null;
}
}
// No catch-all consumed the tail, so the lengths must line up exactly.
if (targetSegs.length !== patSegs.length) return null;
return { params, dynamic, catchAll: 0 };
}
function safeDecode(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
/**
* Resolve a concrete pathname to the route that would render it, plus its
* decoded dynamic params. Returns null when no page route matches (the caller
* then falls back to a normal server round-trip). When several patterns match,
* the most specific wins: fewer catch-alls first, then fewer dynamic segments —
* so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
*/
export function matchRoute(
manifest: MatchableManifest | null | undefined,
pathname: string,
): RouteMatch | null {
if (!manifest || !manifest.routes) return null;
const targetSegs = splitPath(pathname);
let best: { match: RouteMatch; score: number } | null = null;
for (const component of Object.keys(manifest.routes)) {
const route = manifest.routes[component];
if (!route || typeof route.path !== "string") continue;
const m = matchSegments(splitPath(route.path), targetSegs);
if (!m) continue;
const score = m.catchAll * 1000 + m.dynamic;
if (best === null || score < best.score) {
best = { match: { component, params: m.params }, score };
}
}
return best ? best.match : null;
}
/** What `` should warm for a destination href. */
export interface PrefetchTargets {
/** The destination route's own entry chunk, or "" when no page route matches. */
file: string;
/** Chunks to warm alongside it. */
imports: string[];
}
/**
* The chunks a click on `pathname` will need before anything can render: the
* destination route's entry, plus the chunks (React, the client runtime,
* common layouts) it pulls in.
*
* Warming the page payload alone leaves the entry chunk to be fetched after
* the click, and the route cannot render until it lands — so the prefetch
* covers only the half that was already fast.
*
* Size is deliberately NOT a factor. Build output is content-hashed and served
* immutable, so warming a heavy route costs its bytes once per browser and
* makes every later visit to it instant; skipping it would trade a permanent
* win for a one-time saving, and would skip exactly the routes slowest to
* fetch on demand. The warm is deferred to the load event, so those bytes
* never compete with the current page's own render.
*
* An href matching no page route (an API path, a route this build doesn't
* serve) yields the union of every route's chunks: no destination is known,
* but those are needed by any navigation.
*/
export function prefetchTargets(
manifest: MatchableManifest | null | undefined,
pathname: string,
): PrefetchTargets {
if (!manifest || !manifest.routes) return { file: "", imports: [] };
const matched = matchRoute(manifest, pathname);
const route = matched ? manifest.routes[matched.component] : null;
if (route) {
// The route's OWN transitive chunks — everything it needs, and nothing
// belonging to routes the user isn't heading for.
return {
file: route.file || "",
imports: Array.from(new Set(route.imports || [])),
};
}
const imports = new Set();
for (const r of Object.values(manifest.routes)) {
for (const i of r?.imports || []) imports.add(i);
}
return { file: "", imports: Array.from(imports) };
}