@tanstack/router-core
Version:
Modern and scalable routing for React applications
850 lines (795 loc) • 24.4 kB
text/typescript
// Keep this filename free of a secondary extension so declaration generation
// can rewrite relative imports for both ESM and CJS.
import { isNotFound } from './not-found'
import { isRedirect, redirect } from './redirect'
import { rootRouteId } from './root'
import { loadRouteChunk } from './load-client'
import { waitForReason } from './await-signal'
import { getLocationChangeInfo, runRouteLifecycle } from './router'
import type { ParsedLocation } from './location'
import type { AnyRouteMatch } from './Matches'
import type { NotFoundError } from './not-found'
import type {
AnyRoute,
BeforeLoadContextOptions,
LoaderFnContext,
RouteContextOptions,
SsrContextOptions,
} from './route'
import type { AnyRedirect } from './redirect'
import type { AnyRouter, SSROption } from './router'
declare const serverLanePhase: unique symbol
type ServerLane<TPhase extends 'matched' | 'contextualized' | 'reduced'> = {
readonly [serverLanePhase]: TPhase
location: ParsedLocation
matches: Array<AnyRouteMatch>
}
type MatchedLane = ServerLane<'matched'>
type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number]
type ContextualizedLane = ServerLane<'contextualized'> & {
end: number
failure?: IndexedOutcome
}
type ReducedLane = ServerLane<'reduced'>
const SUCCESS = 0
const ERROR = 1
const NOT_FOUND = 2
const REDIRECTED = 3
const SKIPPED = 4
type LoaderOutcome =
| [typeof SUCCESS, data: unknown]
| [typeof ERROR, error: unknown]
| [typeof NOT_FOUND, error: NotFoundError]
| [typeof REDIRECTED, redirect: AnyRedirect]
| [typeof SKIPPED]
type LoaderTask = {
index: number
outcome: Promise<LoaderOutcome>
match: Promise<AnyRouteMatch>
}
export type ServerLoadResult =
| {
type: 'render'
status: 200 | 404 | 500
matches: Array<AnyRouteMatch>
}
| { type: 'redirect'; redirect: AnyRedirect }
function getRoute(router: AnyRouter, match: AnyRouteMatch): AnyRoute {
return router.routesById[match.routeId]
}
function normalize(value: unknown, rejected: boolean): LoaderOutcome {
if (isRedirect(value)) {
return [REDIRECTED, value]
}
if (isNotFound(value)) {
return [NOT_FOUND, value]
}
if (rejected && typeof (value as any)?.then === 'function') {
value = new Error('A Promise was thrown', { cause: value })
}
return rejected ? [ERROR, value] : [SUCCESS, value]
}
function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome {
let outcome = normalize(cause, true)
if (outcome[0] !== ERROR) {
return outcome
}
try {
route.options.onError?.(outcome[1])
} catch (onErrorCause) {
outcome = normalize(onErrorCause, true)
}
return outcome
}
function maybe<TValue>(
value: TValue,
cause: unknown,
): { status: 'success'; value: TValue } | { status: 'error'; error: unknown } {
if (cause !== undefined) {
return { status: 'error', error: cause }
}
return { status: 'success', value }
}
function navigateFrom(router: AnyRouter, location: ParsedLocation) {
return (options: any) =>
router.navigate({
...options,
_fromLocation: location,
})
}
function waitFor<T>(value: Promise<T>, signal?: AbortSignal): Promise<T> {
return signal ? waitForReason(value, signal) : value
}
async function resolveSsr(
router: AnyRouter,
lane: MatchedLane,
index: number,
): Promise<SSROption> {
const match = lane.matches[index]!
const route = getRoute(router, match)
const parentSsr = lane.matches[index - 1]?.ssr
if (router.isShell()) {
return route.id === rootRouteId
}
if (parentSsr === false) {
return false
}
const inherit = (value: SSROption): SSROption => {
return value === true && parentSsr === 'data-only' ? 'data-only' : value
}
const defaultSsr = router.options.defaultSsr ?? true
const inheritedDefault = inherit(defaultSsr)
// A functional override can fail. Establish the inherited policy first so
// the selected error boundary retains the route's actual renderability.
match.ssr = inheritedDefault
const option = route.options.ssr
if (option === undefined) {
return inheritedDefault
}
if (typeof option !== 'function') {
return inherit(option)
}
const context: SsrContextOptions<any, any, any> = {
search: maybe(match.search, match.searchError),
params: maybe(match.params, match.paramsError),
location: lane.location,
matches: lane.matches.map((candidate) => ({
index: candidate.index,
pathname: candidate.pathname,
fullPath: candidate.fullPath,
staticData: candidate.staticData,
id: candidate.id,
routeId: candidate.routeId,
search: maybe(candidate.search, candidate.searchError),
params: maybe(candidate.params, candidate.paramsError),
ssr: candidate.ssr,
})),
}
return inherit((await option(context)) ?? defaultSsr)
}
function stampNotFound(
match: AnyRouteMatch,
outcome: LoaderOutcome,
): LoaderOutcome {
if (outcome[0] === NOT_FOUND && !outcome[1].routeId) {
outcome[1].routeId = match.routeId
}
return outcome
}
async function contextualize(
router: AnyRouter,
lane: MatchedLane,
signal?: AbortSignal,
): Promise<ContextualizedLane> {
const globalBoundary = lane.matches.findIndex((match) => match._notFound)
let end = globalBoundary < 0 ? lane.matches.length : globalBoundary + 1
let failure: IndexedOutcome | undefined
let parentContext: Record<string, unknown> = {
...(router.options.context ?? {}),
}
for (let index = 0; index < end; index++) {
const match = lane.matches[index]!
const route = getRoute(router, match)
try {
match.ssr = await resolveSsr(router, lane, index)
} catch (cause) {
signal?.throwIfAborted()
failure = [index, stampNotFound(match, normalizeError(route, cause))]
end = index
}
signal?.throwIfAborted()
if (failure?.[1][0] === REDIRECTED) {
break
}
match.__beforeLoadContext = undefined
let context = parentContext
try {
let routeContext
if (route.options.context) {
const routeContextOptions: RouteContextOptions<
any,
any,
any,
any,
any
> = {
deps: match.loaderDeps,
params: match.params,
context: parentContext,
location: lane.location,
navigate: navigateFrom(router, lane.location),
buildLocation: router.buildLocation,
cause: match.cause,
abortController: match.abortController,
preload: false,
matches: lane.matches,
routeId: route.id,
}
routeContext = route.options.context(routeContextOptions) ?? undefined
}
context = {
...parentContext,
...routeContext,
}
match.context = context
} catch (cause) {
signal?.throwIfAborted()
if (!failure) {
failure = [index, stampNotFound(match, normalizeError(route, cause))]
}
end = index
break
}
signal?.throwIfAborted()
if (failure) {
break
}
const validationError = match.paramsError ?? match.searchError
if (validationError !== undefined) {
failure = [
index,
stampNotFound(match, normalizeError(route, validationError)),
]
end = index
break
}
signal?.throwIfAborted()
if (match.ssr === false || !route.options.beforeLoad) {
parentContext = context
continue
}
const abortController = match.abortController
const options: BeforeLoadContextOptions<
any,
any,
any,
any,
any,
any,
any,
any,
any
> = {
search: match.search,
abortController,
params: match.params,
preload: false,
context,
location: lane.location,
navigate: navigateFrom(router, lane.location),
buildLocation: router.buildLocation,
cause: match.cause,
matches: lane.matches,
routeId: route.id,
...router.options.additionalContext,
}
try {
const beforeLoadContext = await route.options.beforeLoad(options)
signal?.throwIfAborted()
const outcome = stampNotFound(match, normalize(beforeLoadContext, false))
if (outcome[0] !== SUCCESS) {
failure = [index, outcome]
end = index
break
}
match.__beforeLoadContext = beforeLoadContext
match.context = {
...context,
...beforeLoadContext,
}
parentContext = match.context
} catch (cause) {
signal?.throwIfAborted()
failure = [index, stampNotFound(match, normalizeError(route, cause))]
end = index
break
}
}
return {
location: lane.location,
matches: lane.matches,
end,
failure,
} as ContextualizedLane
}
function getLoaderContext(
router: AnyRouter,
lane: ContextualizedLane,
match: AnyRouteMatch,
route: AnyRoute,
index: number,
tasks: Array<LoaderTask>,
): LoaderFnContext {
return {
params: match.params,
deps: match.loaderDeps,
preload: false,
parentMatchPromise: tasks[index - 1]?.match,
abortController: match.abortController,
context: match.context,
location: lane.location,
navigate: navigateFrom(router, lane.location),
cause: match.cause,
route,
...router.options.additionalContext,
}
}
function createLoaderTask(
router: AnyRouter,
lane: ContextualizedLane,
index: number,
tasks: Array<LoaderTask>,
signal?: AbortSignal,
): LoaderTask {
const match = lane.matches[index]!
const route = getRoute(router, match)
let outcome: Promise<LoaderOutcome>
if (match.ssr === false) {
outcome = Promise.resolve<LoaderOutcome>([SKIPPED])
} else {
const routeLoader = route.options.loader
const loader =
typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
if (!loader) {
outcome = Promise.resolve<LoaderOutcome>([SUCCESS, undefined])
} else {
outcome = Promise.resolve()
.then(() =>
loader(getLoaderContext(router, lane, match, route, index, tasks)),
)
.then(
(result) => normalize(result, false),
(cause) => normalize(cause, true),
)
.then((result): LoaderOutcome => {
if (
result[0] !== REDIRECTED &&
(signal?.aborted || match.abortController.signal.reason === lane)
) {
return [SKIPPED]
}
if (result[0] === ERROR) {
result = normalizeError(route, result[1])
}
return stampNotFound(match, result)
})
}
}
const parentMatch = outcome.then((result) => {
const snapshot = { ...match }
if (result[0] === SUCCESS) {
snapshot.loaderData = result[1]
snapshot.status = 'success'
snapshot.error = undefined
snapshot.invalid = false
snapshot.isFetching = false
} else if (result[0] === ERROR) {
snapshot.status = 'error'
snapshot.error = result[1]
} else if (result[0] === NOT_FOUND) {
snapshot.status = 'notFound'
snapshot.error = result[1]
}
return snapshot
})
return { index, outcome, match: parentMatch }
}
async function getNotFoundBoundary(
router: AnyRouter,
matches: Array<AnyRouteMatch>,
indexed: IndexedOutcome | undefined,
signal?: AbortSignal,
fallback = 0,
): Promise<number> {
const cause = indexed?.[1][1] as NotFoundError | undefined
let index = cause?.routeId
? matches.findIndex((match) => match.routeId === cause.routeId)
: (indexed?.[0] ?? matches.length - 1)
if (index < 0) {
index = 0
}
for (let candidate = index; candidate >= 0; candidate--) {
const route = getRoute(router, matches[candidate]!)
const loading = loadRouteChunk(route, false)
if (loading) {
try {
await loading
} catch {
signal?.throwIfAborted()
}
}
signal?.throwIfAborted()
if (route.options.notFoundComponent) {
return candidate
}
}
return cause?.routeId ? index : fallback
}
function abortMatches(
matches: Array<AnyRouteMatch>,
start = 0,
reason?: unknown,
): void {
for (let index = start; index < matches.length; index++) {
matches[index]!.abortController.abort(reason)
}
}
function resolveServerRedirect(
router: AnyRouter,
location: ParsedLocation,
value: AnyRedirect,
): ServerLoadResult {
value.options._fromLocation = location
return { type: 'redirect', redirect: router.resolveRedirect(value) }
}
async function applyFailure(
router: AnyRouter,
lane: ContextualizedLane,
indexed: IndexedOutcome | undefined,
signal?: AbortSignal,
): Promise<{ status: 200 | 404 | 500; boundary?: number; kind?: number }> {
if (!indexed) {
const boundary = lane.matches.findIndex((match) => match._notFound)
if (boundary >= 0) {
abortMatches(lane.matches, boundary + 1)
return { status: 404, boundary, kind: NOT_FOUND }
}
return { status: 200 }
}
const [index, outcome] = indexed
if (outcome[0] === ERROR) {
const match = lane.matches[index]!
match._notFound = undefined
match.status = 'error'
match.error = outcome[1]
match.isFetching = false
abortMatches(lane.matches, index + 1)
return { status: 500, boundary: index, kind: ERROR }
}
const boundary =
indexed[2] ??
(await getNotFoundBoundary(router, lane.matches, indexed, signal))
const match = lane.matches[boundary]!
const cause = outcome[1] as NotFoundError
cause.routeId = match.routeId
match._notFound = undefined
if (match.routeId === router.routeTree.id) {
match.status = 'success'
match._notFound = true
match.error = cause
} else {
match.status = 'notFound'
match.error = cause
}
match.isFetching = false
abortMatches(lane.matches, boundary + 1)
return { status: 404, boundary, kind: NOT_FOUND }
}
async function loadNormalChunks(
router: AnyRouter,
lane: ContextualizedLane,
end: number,
signal?: AbortSignal,
): Promise<IndexedOutcome | undefined> {
const chunks: Array<IndexedOutcome | Promise<IndexedOutcome | undefined>> = []
for (let index = 0; index < lane.matches.length; index++) {
const match = lane.matches[index]!
if (index >= end || match.ssr !== true || match.status !== 'success') {
continue
}
const route = getRoute(router, match)
try {
const loading = loadRouteChunk(route)
if (loading) {
const chunk = loading.then(
() => {
signal?.throwIfAborted()
return undefined
},
(cause) => {
signal?.throwIfAborted()
return [
index,
stampNotFound(match, normalizeError(route, cause)),
] as IndexedOutcome
},
)
// Route-order reduction can return before later chunks settle.
void chunk.catch(() => {})
chunks.push(chunk)
}
} catch (cause) {
signal?.throwIfAborted()
chunks.push([index, stampNotFound(match, normalizeError(route, cause))])
}
}
for (const chunk of chunks) {
const indexed = Array.isArray(chunk) ? chunk : await chunk
if (indexed) {
return indexed
}
}
return undefined
}
async function projectLane(
router: AnyRouter,
lane: ReducedLane,
signal?: AbortSignal,
): Promise<void> {
for (const match of lane.matches) {
const routeOptions = getRoute(router, match).options
if (routeOptions.head || routeOptions.scripts || routeOptions.headers) {
const context = {
ssr: router.options.ssr,
matches: lane.matches,
match,
params: match.params,
loaderData: match.loaderData,
}
try {
const [head, scripts, headers] = await Promise.all([
routeOptions.head?.(context),
routeOptions.scripts?.(context),
routeOptions.headers?.(context),
])
signal?.throwIfAborted()
match.meta = head?.meta
match.links = head?.links
match.headScripts = head?.scripts
match.styles = head?.styles
match.scripts = scripts
match.headers = headers
} catch (cause) {
signal?.throwIfAborted()
console.error(cause)
}
}
if (match.ssr === false || match.status !== 'success' || match._notFound) {
break
}
}
}
async function executeServerLane(
router: AnyRouter,
location: ParsedLocation,
matchedMatches: Array<AnyRouteMatch>,
signal?: AbortSignal,
): Promise<ServerLoadResult> {
const matched = {
location,
matches: matchedMatches.map((match) => ({
...match,
__beforeLoadContext: undefined,
context: {},
isFetching: false,
abortController: new AbortController(),
})),
} as MatchedLane
const abortLane = () => abortMatches(matched.matches, 0, signal?.reason)
if (signal?.aborted) {
abortLane()
signal.throwIfAborted()
}
signal?.addEventListener('abort', abortLane, { once: true })
try {
const plannedGlobalBoundary = matched.matches.findIndex(
(match) => match._notFound,
)
if (router.options.notFoundMode !== 'root' && plannedGlobalBoundary >= 0) {
const boundary = await getNotFoundBoundary(
router,
matched.matches,
undefined,
signal,
plannedGlobalBoundary,
)
if (boundary !== plannedGlobalBoundary) {
matched.matches[plannedGlobalBoundary]!._notFound = undefined
matched.matches[boundary]!._notFound = true
}
}
const lane = await contextualize(router, matched, signal)
signal?.throwIfAborted()
let loaderEnd = lane.end
if (lane.failure?.[1][0] === REDIRECTED) {
loaderEnd = 0
} else if (lane.failure?.[1][0] === NOT_FOUND) {
lane.failure[2] = await getNotFoundBoundary(
router,
lane.matches,
lane.failure,
signal,
)
loaderEnd = Math.min(loaderEnd, lane.failure[2] + 1)
}
const tasks: Array<LoaderTask> = []
for (let index = 0; index < loaderEnd; index++) {
const task = createLoaderTask(router, lane, index, tasks, signal)
tasks.push(task)
}
let loaderFailure: IndexedOutcome | undefined
let control = lane.failure?.[1][0] === REDIRECTED ? lane.failure : undefined
try {
await Promise.all(
tasks.map((task) =>
task.outcome.then((loadedOutcome) => {
const match = lane.matches[task.index]!
const outcome = loadedOutcome
if (outcome[0] === SUCCESS) {
match.loaderData = outcome[1]
match.status = 'success'
match.error = undefined
match.invalid = false
match.isFetching = false
match.updatedAt = Date.now()
} else if (outcome[0] === REDIRECTED) {
control = [task.index, outcome]
throw control
} else {
// A selective-SSR skip must stay pending for hydration. Every
// settled server attempt is otherwise renderable unless
// reduction selects it as the lane's terminal failure.
if (match.ssr !== false) {
match.status = 'success'
match.error = undefined
match.invalid = true
match.isFetching = false
}
if (!loaderFailure && outcome[0] !== SKIPPED) {
loaderFailure = [task.index, outcome]
}
}
}),
),
)
} catch (cause) {
if (!Array.isArray(cause)) {
throw cause
}
control = cause as IndexedOutcome
}
signal?.throwIfAborted()
if (control?.[1][0] === REDIRECTED) {
abortMatches(lane.matches, 0, lane)
return resolveServerRedirect(router, location, control[1][1])
}
let failure = lane.failure ?? loaderFailure
const plannedBoundary = lane.matches.findIndex((match) => match._notFound)
let readinessEnd: number
if (failure) {
const outcomeEnd = (failure[2] ??=
failure[1][0] === NOT_FOUND
? await getNotFoundBoundary(router, lane.matches, failure, signal)
: failure[0])
for (const task of tasks) {
if (task.index >= outcomeEnd) {
break
}
const outcome = await task.outcome
// Presence means a loader previously succeeded, even with `undefined`.
if (
outcome[0] !== SUCCESS &&
outcome[0] < REDIRECTED &&
!('loaderData' in lane.matches[task.index]!)
) {
failure = [task.index, outcome]
failure[2] =
outcome[0] === NOT_FOUND
? await getNotFoundBoundary(router, lane.matches, failure, signal)
: task.index
break
}
}
readinessEnd = failure[2]
} else {
readinessEnd = plannedBoundary < 0 ? lane.matches.length : plannedBoundary
}
const requiredFailure = await loadNormalChunks(
router,
lane,
readinessEnd,
signal,
)
signal?.throwIfAborted()
if (requiredFailure) {
if (requiredFailure[1][0] === REDIRECTED) {
abortMatches(lane.matches)
return resolveServerRedirect(router, location, requiredFailure[1][1])
}
failure = requiredFailure
}
const terminal = await applyFailure(router, lane, failure, signal)
if (terminal.boundary !== undefined) {
const match = lane.matches[terminal.boundary]!
if (match.ssr === true) {
const route = getRoute(router, match)
try {
if (terminal.kind === ERROR) {
await loadRouteChunk(route, 'errorComponent')
} else if (match._notFound) {
await Promise.all([
loadRouteChunk(route),
loadRouteChunk(route, 'notFoundComponent'),
])
} else {
await loadRouteChunk(route, 'notFoundComponent')
}
} catch {}
signal?.throwIfAborted()
}
}
signal?.throwIfAborted()
await projectLane(
router,
{
location: lane.location,
matches: lane.matches,
} as ReducedLane,
signal,
)
signal?.throwIfAborted()
router.serverSsr?.onCleanup(abortLane)
return { type: 'render', status: terminal.status, matches: lane.matches }
} finally {
signal?.removeEventListener('abort', abortLane)
}
}
type ServerLoadOptions = NonNullable<Parameters<AnyRouter['load']>[0]> & {
_signal?: AbortSignal
}
export async function loadServerRoute(
router: AnyRouter,
opts?: ServerLoadOptions,
): Promise<void> {
router.updateLatestLocation()
const next = router.latestLocation
const previous = router._committed
let result: ServerLoadResult
try {
const canonical = router.buildLocation({
to: next.pathname,
search: true,
params: true,
hash: true,
state: true,
_includeValidateSearch: true,
})
if (next.publicHref !== canonical.publicHref) {
const href = canonical.publicHref || '/'
throw canonical.external
? redirect({ href })
: redirect({ href, _builtLocation: canonical })
}
const fromLocation = router.stores.resolvedLocation.get()
const changeInfo = getLocationChangeInfo(next, fromLocation)
router.emit({ type: 'onBeforeNavigate', ...changeInfo })
router.emit({ type: 'onBeforeLoad', ...changeInfo })
opts?._signal?.throwIfAborted()
result = await waitFor(
executeServerLane(router, next, router.matchRoutes(next), opts?._signal),
opts?._signal,
)
opts?._signal?.throwIfAborted()
} catch (cause) {
opts?._signal?.throwIfAborted()
if (!isRedirect(cause)) {
throw cause
}
result = resolveServerRedirect(router, next, cause)
}
router._serverResult = result
router.batch(() => {
router.stores.location.set(next)
router.stores.status.set('idle')
if (result.type === 'render') {
router.stores.setMatches(result.matches)
router.stores.resolvedLocation.set(next)
}
})
if (result.type === 'render') {
router._committed = result.matches
runRouteLifecycle(router, previous, result.matches)
}
router._commitPromise?.resolve()
router._commitPromise = undefined
}