UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

850 lines (795 loc) 24.4 kB
// 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 }