'use client' import * as React from 'react' import { useStore } from '@tanstack/react-store' import { createControlledPromise, getLocationChangeInfo, invariant, isNotFound, isRedirect, rootRouteId, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' import { matchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' import { useLayoutEffect } from './utils' import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' export const Match = React.memo(function MatchImpl({ matchId, }: { matchId: string }) { const router = useRouter() if (isServer ?? router.isServer) { const match = router.stores.matchStores.get(matchId)?.get() if (!match) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, ) } invariant() } const routeId = match.routeId as string const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute ?.id return ( ) } // Subscribe directly to the match store from the pool. // The matchId prop is stable for this component's lifetime (set by Outlet), // and reconcileMatchPool reuses stores for the same matchId. const matchStore = router.stores.matchStores.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, ) } invariant() } // eslint-disable-next-line react-hooks/rules-of-hooks const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) // eslint-disable-next-line react-hooks/rules-of-hooks const match = useStore(matchStore, (value) => value) // eslint-disable-next-line react-hooks/rules-of-hooks const matchState = React.useMemo(() => { const routeId = match.routeId as string const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute ?.id return { routeId, ssr: match.ssr, _displayPending: match._displayPending, parentRouteId: parentRouteId as string | undefined, } satisfies MatchViewState }, [match._displayPending, match.routeId, match.ssr, router.routesById]) return ( ) }) type MatchViewState = { routeId: string ssr: boolean | 'data-only' | undefined _displayPending: boolean | undefined parentRouteId: string | undefined } function MatchView({ router, matchId, resetKey, matchState, }: { router: ReturnType matchId: string resetKey: number matchState: MatchViewState }) { const route: AnyRoute = router.routesById[matchState.routeId] const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent const pendingElement = PendingComponent ? : null const routeErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent const routeOnCatch = route.options.onCatch ?? router.options.defaultOnCatch const routeNotFoundComponent = route.isRoot ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component (route.options.notFoundComponent ?? router.options.notFoundRoute?.options.component) : route.options.notFoundComponent const resolvedNoSsr = matchState.ssr === false || matchState.ssr === 'data-only' const ResolvedSuspenseBoundary = // If we're on the root route, allow forcefully wrapping in suspense (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) && (route.options.wrapInSuspense ?? PendingComponent ?? ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) ? React.Suspense : SafeFragment const ResolvedCatchBoundary = routeErrorComponent ? CatchBoundary : SafeFragment const ResolvedNotFoundBoundary = routeNotFoundComponent ? CatchNotFound : SafeFragment const ShellComponent = route.isRoot ? ((route.options as RootRouteOptions).shellComponent ?? SafeFragment) : SafeFragment return ( resetKey} errorComponent={routeErrorComponent || ErrorComponent} onCatch={(error, errorInfo) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { error.routeId ??= matchState.routeId as any throw error } if (process.env.NODE_ENV !== 'production') { console.warn(`Warning: Error in route match: ${matchId}`) } routeOnCatch?.(error, errorInfo) }} > { error.routeId ??= matchState.routeId as any // If the current not found handler doesn't exist or it has a // route ID which doesn't match the current route, rethrow the error if ( !routeNotFoundComponent || (error.routeId && error.routeId !== matchState.routeId) || (!error.routeId && !route.isRoot) ) throw error return React.createElement(routeNotFoundComponent, error as any) }} > {resolvedNoSsr || matchState._displayPending ? ( ) : ( )} {matchState.parentRouteId === rootRouteId ? ( <> {router.options.scrollRestoration && (isServer ?? router.isServer) ? ( ) : null} ) : null} ) } // On Rendered can't happen above the root layout because it needs to run after // the route subtree has committed below the root layout. Keeping it here lets // us fire onRendered even after a hydration mismatch above the root layout // (like bad head/link tags, which is common). function OnRendered({ resetKey }: { resetKey: number }) { const router = useRouter() if (isServer ?? router.isServer) { return null } // eslint-disable-next-line react-hooks/rules-of-hooks const prevHrefRef = React.useRef(undefined) // eslint-disable-next-line react-hooks/rules-of-hooks useLayoutEffect(() => { const currentHref = router.latestLocation.href if ( prevHrefRef.current === undefined || prevHrefRef.current !== currentHref ) { router.emit({ type: 'onRendered', ...getLocationChangeInfo( router.stores.location.get(), router.stores.resolvedLocation.get(), ), }) prevHrefRef.current = currentHref } }, [router.latestLocation.state.__TSR_key, resetKey, router]) return null } export const MatchInner = React.memo(function MatchInnerImpl({ matchId, }: { matchId: string }): any { const router = useRouter() const getMatchPromise = ( match: { id: string _nonReactive: { displayPendingPromise?: Promise minPendingPromise?: Promise loadPromise?: Promise } }, key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise', ) => { return ( router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key] ) } if (isServer ?? router.isServer) { const match = router.stores.matchStores.get(matchId)?.get() if (!match) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, ) } invariant() } const routeId = match.routeId as string const route = router.routesById[routeId] as AnyRoute const remountFn = (router.routesById[routeId] as AnyRoute).options.remountDeps ?? router.options.defaultRemountDeps const remountDeps = remountFn?.({ routeId, loaderDeps: match.loaderDeps, params: match._strictParams, search: match._strictSearch, }) const key = remountDeps ? JSON.stringify(remountDeps) : undefined const Comp = route.options.component ?? router.options.defaultComponent const out = Comp ? : if (match._displayPending) { throw getMatchPromise(match, 'displayPendingPromise') } if (match._forcePending) { throw getMatchPromise(match, 'minPendingPromise') } if (match.status === 'pending') { throw getMatchPromise(match, 'loadPromise') } if (match.status === 'notFound') { if (!isNotFound(match.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a notFound error') } invariant() } return renderRouteNotFound(router, route, match.error) } if (match.status === 'redirected') { if (!isRedirect(match.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a redirect error') } invariant() } throw getMatchPromise(match, 'loadPromise') } if (match.status === 'error') { const RouteErrorComponent = (route.options.errorComponent ?? router.options.defaultErrorComponent) || ErrorComponent return ( ) } return out } const matchStore = router.stores.matchStores.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find match for matchId "${matchId}". Please file an issue!`, ) } invariant() } // eslint-disable-next-line react-hooks/rules-of-hooks const match = useStore(matchStore, (value) => value) const routeId = match.routeId as string const route = router.routesById[routeId] as AnyRoute // eslint-disable-next-line react-hooks/rules-of-hooks const key = React.useMemo(() => { const remountFn = (router.routesById[routeId] as AnyRoute).options.remountDeps ?? router.options.defaultRemountDeps const remountDeps = remountFn?.({ routeId, loaderDeps: match.loaderDeps, params: match._strictParams, search: match._strictSearch, }) return remountDeps ? JSON.stringify(remountDeps) : undefined }, [ routeId, match.loaderDeps, match._strictParams, match._strictSearch, router.options.defaultRemountDeps, router.routesById, ]) // eslint-disable-next-line react-hooks/rules-of-hooks const out = React.useMemo(() => { const Comp = route.options.component ?? router.options.defaultComponent if (Comp) { return } return }, [key, route.options.component, router.options.defaultComponent]) if (match._displayPending) { throw getMatchPromise(match, 'displayPendingPromise') } if (match._forcePending) { throw getMatchPromise(match, 'minPendingPromise') } // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts if (match.status === 'pending') { // We're pending, and if we have a minPendingMs, we need to wait for it const pendingMinMs = route.options.pendingMinMs ?? router.options.defaultPendingMinMs if (pendingMinMs) { const routerMatch = router.getMatch(match.id) if (routerMatch && !routerMatch._nonReactive.minPendingPromise) { // Create a promise that will resolve after the minPendingMs if (!(isServer ?? router.isServer)) { const minPendingPromise = createControlledPromise() routerMatch._nonReactive.minPendingPromise = minPendingPromise setTimeout(() => { minPendingPromise.resolve() // We've handled the minPendingPromise, so we can delete it routerMatch._nonReactive.minPendingPromise = undefined }, pendingMinMs) } } } throw getMatchPromise(match, 'loadPromise') } if (match.status === 'notFound') { if (!isNotFound(match.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a notFound error') } invariant() } return renderRouteNotFound(router, route, match.error) } if (match.status === 'redirected') { // A match can be observed as redirected during an in-flight transition, // especially when pending UI is already rendering. Suspend on the match's // load promise so React can abandon this stale render and continue the // redirect transition. if (!isRedirect(match.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a redirect error') } invariant() } throw getMatchPromise(match, 'loadPromise') } if (match.status === 'error') { // If we're on the server, we need to use React's new and super // wonky api for throwing errors from a server side render inside // of a suspense boundary. This is the only way to get // renderToPipeableStream to not hang indefinitely. // We'll serialize the error and rethrow it on the client. if (isServer ?? router.isServer) { const RouteErrorComponent = (route.options.errorComponent ?? router.options.defaultErrorComponent) || ErrorComponent return ( ) } throw match.error } return out }) /** * Render the next child match in the route tree. Typically used inside * a route component to render nested routes. * * @link https://tanstack.com/router/latest/docs/framework/react/api/router/outletComponent */ export const Outlet = React.memo(function OutletImpl() { const router = useRouter() const matchId = React.useContext(matchContext) let routeId: string | undefined let parentGlobalNotFound = false let childMatchId: string | undefined if (isServer ?? router.isServer) { const matches = router.stores.matches.get() const parentIndex = matchId ? matches.findIndex((match) => match.id === matchId) : -1 const parentMatch = parentIndex >= 0 ? matches[parentIndex] : undefined routeId = parentMatch?.routeId as string | undefined parentGlobalNotFound = parentMatch?.globalNotFound ?? false childMatchId = parentIndex >= 0 ? (matches[parentIndex + 1]?.id as string) : undefined } else { // Subscribe directly to the match store from the pool instead of // the two-level byId → matchStore pattern. const parentMatchStore = matchId ? router.stores.matchStores.get(matchId) : undefined // eslint-disable-next-line react-hooks/rules-of-hooks ;[routeId, parentGlobalNotFound] = useStore(parentMatchStore, (match) => [ match?.routeId as string | undefined, match?.globalNotFound ?? false, ]) // eslint-disable-next-line react-hooks/rules-of-hooks childMatchId = useStore(router.stores.matchesId, (ids) => { const index = ids.findIndex((id) => id === matchId) return ids[index + 1] }) } const route = routeId ? router.routesById[routeId] : undefined const pendingElement = router.options.defaultPendingComponent ? ( ) : null if (parentGlobalNotFound) { if (!route) { if (process.env.NODE_ENV !== 'production') { throw new Error( 'Invariant failed: Could not resolve route for Outlet render', ) } invariant() } return renderRouteNotFound(router, route, undefined) } if (!childMatchId) { return null } const nextMatch = if (routeId === rootRouteId) { return ( {nextMatch} ) } return nextMatch })