/** * Match API * * Extracted from createRouter closure. Contains match context creation functions * and the matchError function for error boundary resolution. */ import { CacheScope, createCacheScope } from "../cache/cache-scope.js"; import { RouteNotFoundError } from "../errors"; import { createErrorInfo, createErrorSegment, findNearestErrorBoundary as findErrorBoundary, } from "./error-handling.js"; import { createHandlerContext, stripInternalParams, } from "./handler-context.js"; import { setupLoaderAccess } from "./loader-resolution.js"; import { loadManifest, clearManifestCache } from "./manifest.js"; import { collectRouteMiddleware } from "./middleware.js"; import { traverseBack } from "./pattern-matching.js"; import { DefaultErrorFallback } from "../default-error-boundary.js"; import { type EntryData, type LoaderEntry, getContext, type InterceptSelectorContext, } from "../server/context"; import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types"; import type { ReactNode } from "react"; import type { MatchContext } from "./match-context.js"; import type { MatchApiDeps, ActionContext } from "./types.js"; import { getRequestContext, setRequestContextPrevRouteKey, } from "../server/request-context.js"; import { isAutoGeneratedRouteName } from "../route-name.js"; import type { DefaultRouteName } from "../types/global-namespace.js"; import { debugLog, debugWarn } from "./logging.js"; import { resolveRoute, ensureFullRouteSnapshot, type RouteSnapshot, } from "./route-snapshot.js"; import { resolveNavigation } from "./navigation-snapshot.js"; /** * Create match context for full requests (document/SSR). */ export async function createMatchContextForFull( request: Request, env: TEnv, deps: MatchApiDeps, findInterceptForRoute: MatchApiDeps["findInterceptForRoute"], ): Promise | { type: "redirect"; redirectUrl: string }> { const url = new URL(request.url); const pathname = url.pathname; const metricsStore = deps.getMetricsStore(); // Full renders always resolve fresh with isSSR: true because loadManifest // keys its cache on isSSR and stamps Store.isSSR for downstream behavior. const result = await resolveRoute(pathname, { findMatch: (p) => deps.findMatch(p, metricsStore), metricsStore, isSSR: true, }); if (!result) { throw new RouteNotFoundError(`No route matched for ${pathname}`, { cause: { pathname, method: request.method }, }); } if (result.type === "redirect") { return { type: "redirect", redirectUrl: result.redirectTo + url.search, }; } const snapshot = result.snapshot; const { matched } = snapshot; // Backward compat: downstream middleware reads matched.pt if (snapshot.isPassthrough) { matched.pt = true; } // Clean URL without internal _rsc* params for userland access const cleanUrl = stripInternalParams(url); const handlerContext = createHandlerContext( matched.params, request, cleanUrl.searchParams, pathname, cleanUrl, env, deps.getRouteMap(), matched.routeKey, matched.responseType, matched.pt === true, ); const loaderPromises = new Map>(); setupLoaderAccess(handlerContext, loaderPromises); const Store = getContext().getOrCreateStore(matched.routeKey); Store.run = (fn: () => T | Promise) => getContext().runWithStore( Store, Store.namespace || "#router", Store.parent, fn, ); if (metricsStore) { Store.metrics = metricsStore; } return { request, url: cleanUrl, pathname, env, clientSegmentIds: [], clientSegmentSet: new Set(), stale: false, prevUrl: cleanUrl, prevParams: {}, prevMatch: null, matched, manifestEntry: snapshot.manifestEntry, entries: snapshot.entries, routeKey: matched.routeKey, localRouteName: snapshot.localRouteName, handlerContext, loaderPromises, routeMap: deps.getRouteMap(), metricsStore, Store, interceptContextMatch: null, interceptSelectorContext: { from: cleanUrl, to: cleanUrl, params: matched.params, request, env, segments: { path: [], ids: [] }, toRouteName: matched.routeKey && !isAutoGeneratedRouteName(matched.routeKey) ? (matched.routeKey as DefaultRouteName) : undefined, }, isSameRouteNavigation: false, interceptResult: null, cacheScope: snapshot.cacheScope, isIntercept: false, actionContext: undefined, isAction: false, routeMiddleware: snapshot.routeMiddleware, isFullMatch: true, }; } /** * Create match context for partial requests (navigation/actions). */ export async function createMatchContextForPartial( request: Request, env: TEnv, deps: MatchApiDeps, findInterceptForRoute: MatchApiDeps["findInterceptForRoute"], actionContext?: ActionContext, ): Promise | null> { const url = new URL(request.url); const pathname = url.pathname; const metricsStore = deps.getMetricsStore(); const isHmr = !!request.headers.get("X-RSC-HMR"); // HMR: clear manifest cache so stale handler references are discarded if (isHmr) { clearManifestCache(); } // Reuse the classified snapshot when available and not invalidated by HMR. // classifyRequest already called resolveRoute(lite) with isSSR=false, which // matches the partial path. On HMR, discard to pick up manifest changes. const classifiedRoute = isHmr ? undefined : getRequestContext()?._classifiedRoute; // Time route matching. On the reuse path, only nav findMatch calls are new // (current-route findMatch and manifest-loading were already timed during // classifyRequest via its metricsStore). On the fresh path, all findMatch // calls are measured together. const routeMatchStart = metricsStore ? performance.now() : 0; let snapshot: RouteSnapshot; if (classifiedRoute && classifiedRoute.manifestEntry) { snapshot = ensureFullRouteSnapshot(classifiedRoute); } else { const result = await resolveRoute(pathname, { findMatch: (p) => deps.findMatch(p, metricsStore), metricsStore, isSSR: false, skipRouteMatchMetric: true, }); if (!result) { throw new RouteNotFoundError(`No route matched for ${pathname}`, { cause: { pathname, method: request.method }, }); } if (result.type === "redirect") { return null; } snapshot = result.snapshot; } const { matched } = snapshot; // Backward compat: downstream middleware reads matched.pt if (snapshot.isPassthrough) { matched.pt = true; } // Navigation state (prev + intercept-source findMatch calls) const nav = resolveNavigation(request, url, matched.routeKey, { findMatch: deps.findMatch, }); if (!nav) { return null; } // Push route-matching metric. On the fresh path this covers all findMatch // calls (current + prev + intercept-source). On the reuse path, current-route // findMatch was already timed during classification, so this only covers // the nav lookups (prev + intercept-source). if (metricsStore) { const isReuse = !!classifiedRoute; metricsStore.metrics.push({ label: isReuse ? "route-matching:nav" : "route-matching", duration: performance.now() - routeMatchStart, startTime: routeMatchStart - metricsStore.requestStart, }); } if (nav.prevMatch && nav.prevMatch.entry !== matched.entry && !matched.pr) { debugLog("matchPartial", "route group changed", { from: nav.prevMatch.routeKey, to: matched.routeKey, }); } // Clean URL without internal _rsc* params for userland access const cleanUrl = stripInternalParams(url); const handlerContext = createHandlerContext( matched.params, request, cleanUrl.searchParams, pathname, cleanUrl, env, deps.getRouteMap(), matched.routeKey, matched.responseType, matched.pt === true, ); debugLog("matchPartial", "client segments", { segments: Array.from(nav.clientSegmentSet), }); const loaderPromises = new Map>(); setupLoaderAccess(handlerContext, loaderPromises); const Store = getContext().getOrCreateStore(matched.routeKey); Store.run = (fn: () => T | Promise) => getContext().runWithStore( Store, Store.namespace || "#router", Store.parent, fn, ); if (metricsStore) { Store.metrics = metricsStore; } if (nav.hasInterceptSource) { debugLog("matchPartial.intercept", "intercept context detected", { currentUrl: pathname, interceptSource: nav.interceptContextUrl.href, contextRoute: nav.interceptContextMatch?.routeKey, currentRoute: matched.routeKey, sameRouteNavigation: nav.isSameRouteNavigation, }); } // Store previous route key on the request context for revalidation // fromRouteName. Uses effectiveFromMatch so intercept-source navigations // see the intercept origin route, not the plain previous URL route. setRequestContextPrevRouteKey(nav.effectiveFromMatch?.routeKey); const interceptSelectorContext: InterceptSelectorContext = { from: nav.effectiveFromUrl, to: cleanUrl, params: matched.params, request, env, segments: { path: nav.effectiveFromUrl.pathname.split("/").filter(Boolean), ids: nav.filteredSegmentIds, }, fromRouteName: nav.effectiveFromMatch?.routeKey && !isAutoGeneratedRouteName(nav.effectiveFromMatch.routeKey) ? (nav.effectiveFromMatch.routeKey as DefaultRouteName) : undefined, toRouteName: matched.routeKey && !isAutoGeneratedRouteName(matched.routeKey) ? (matched.routeKey as DefaultRouteName) : undefined, }; const isAction = !!actionContext; const clientHasInterceptSegments = [...nav.clientSegmentSet].some((id) => id.includes(".@"), ); const skipInterceptForAction = isAction && !clientHasInterceptSegments; const interceptResult = nav.isSameRouteNavigation || skipInterceptForAction ? null : findInterceptForRoute( matched.routeKey, snapshot.manifestEntry.parent, interceptSelectorContext, isAction, ) || (snapshot.localRouteName !== matched.routeKey ? findInterceptForRoute( snapshot.localRouteName, snapshot.manifestEntry.parent, interceptSelectorContext, isAction, ) : null); // Make a mutable copy of clientSegmentSet so we can delete entries // for same-route navigation forcing const clientSegmentSet = new Set(nav.clientSegmentSet); if ( nav.isSameRouteNavigation && snapshot.manifestEntry.type === "route" && nav.hasInterceptSource ) { debugLog("matchPartial.intercept", "forcing route segment render", { segmentId: snapshot.manifestEntry.shortCode, }); clientSegmentSet.delete(snapshot.manifestEntry.shortCode); } const isIntercept = !!interceptResult; return { request, url: cleanUrl, pathname, env, clientSegmentIds: nav.clientSegmentIds, clientSegmentSet, stale: nav.stale, prevUrl: nav.prevUrl, prevParams: nav.prevParams, prevMatch: nav.prevMatch, matched, manifestEntry: snapshot.manifestEntry, entries: snapshot.entries, routeKey: matched.routeKey, localRouteName: snapshot.localRouteName, handlerContext, loaderPromises, routeMap: deps.getRouteMap(), metricsStore, Store, interceptContextMatch: nav.interceptContextMatch, interceptSelectorContext, isSameRouteNavigation: nav.isSameRouteNavigation, interceptResult, cacheScope: snapshot.cacheScope, isIntercept, actionContext, isAction, routeMiddleware: snapshot.routeMiddleware, isFullMatch: false, }; } /** * Match an error to the nearest error boundary and return error segments. */ export async function matchError( request: Request, _context: TEnv, error: unknown, deps: MatchApiDeps, defaultErrorBoundary: ReactNode | ErrorBoundaryHandler | undefined, segmentType: ErrorInfo["segmentType"] = "route", ): Promise { const url = new URL(request.url); const pathname = url.pathname; debugLog("matchError", "matching error", { pathname }); const matched = deps.findMatch(pathname); if (!matched) { debugWarn("matchError", "no route matched", { pathname }); return null; } const manifestEntry = await loadManifest( matched.entry, matched.routeKey, pathname, undefined, false, ); const findNearestErrorBoundary = (entry: EntryData | null) => findErrorBoundary(entry, defaultErrorBoundary); const fallback = findNearestErrorBoundary(manifestEntry); const useDefaultFallback = !fallback; const errorInfo = createErrorInfo( error, manifestEntry.shortCode || "unknown", segmentType, ); let entryWithBoundary: EntryData | null = null; let current: EntryData | null = manifestEntry; while (current) { if (current.errorBoundary && current.errorBoundary.length > 0) { entryWithBoundary = current; break; } if (current.layout && current.layout.length > 0) { for (const orphan of current.layout) { if (orphan.errorBoundary && orphan.errorBoundary.length > 0) { entryWithBoundary = orphan; break; } } if (entryWithBoundary) break; } current = current.parent; } let boundaryEntry: EntryData; let outletEntry: EntryData; if (entryWithBoundary) { boundaryEntry = entryWithBoundary; outletEntry = manifestEntry; current = manifestEntry; while (current) { if (current.parent === boundaryEntry) { outletEntry = current; break; } if (current.parent && current.parent.layout) { if (current.parent.layout.includes(boundaryEntry)) { outletEntry = current; break; } } current = current.parent; } } else { let rootEntry = manifestEntry; while (rootEntry.parent) { rootEntry = rootEntry.parent; } boundaryEntry = rootEntry; outletEntry = rootEntry; } const matchedIds: string[] = []; current = boundaryEntry; const stack: { shortCode: string; loaderEntries: LoaderEntry[]; }[] = []; while (current) { if (current.shortCode) { stack.push({ shortCode: current.shortCode, loaderEntries: current.loader || [], }); } current = current.parent; } for (const item of stack.reverse()) { matchedIds.push(item.shortCode); for (let i = 0; i < item.loaderEntries.length; i++) { const loaderId = item.loaderEntries[i].loader?.$$id || "unknown"; matchedIds.push(`${item.shortCode}D${i}.${loaderId}`); } } const reqCtx = getRequestContext(); if (reqCtx) { reqCtx._setStatus(500); } const effectiveFallback = fallback || DefaultErrorFallback; const errorSegment = createErrorSegment( errorInfo, effectiveFallback, outletEntry, matched.params, ); if (useDefaultFallback) { debugLog("matchError", "using default error boundary"); } debugLog("matchError", "resolved boundary", { boundarySegmentId: boundaryEntry.shortCode, outletSegmentId: outletEntry.shortCode, }); return { segments: [errorSegment], matched: matchedIds, diff: [errorSegment.id], resolvedIds: [errorSegment.id], params: matched.params, }; }