UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

255 lines 12.3 kB
import * as Vue from 'vue'; import { isNotFound, rootRouteId } from '@tanstack/router-core'; import { isServer } from '@tanstack/router-core/isServer'; import { useStore } from '@tanstack/vue-store'; import { CatchBoundary } from './CatchBoundary'; import { ClientOnly } from './ClientOnly'; import { useRouter } from './useRouter'; import { CatchNotFound } from './not-found'; import { routeIdContext } from './matchContext'; import { renderRouteNotFound } from './renderRouteNotFound'; import { ScrollRestoration } from './scroll-restoration'; import { nonRouteComponentContext, renderInNonRouteComponentContext, } from './nonRouteComponentContext'; export const Match = Vue.defineComponent({ name: 'Match', props: { routeId: { type: String, required: true, }, }, setup(props) { const router = useRouter(); const routeId = props.routeId; const activeMatch = useStore(router.stores.getMatchStore(routeId), (value) => value, { equal: Object.is }); // Provide routeId context (stable string) for children. // MatchInner, Outlet, and useMatch all consume this. Vue.provide(routeIdContext, routeId); return () => { const match = activeMatch.value; const route = match ? router.routesById[routeId] : undefined; const PendingComponent = route?.options.pendingComponent ?? router.options.defaultPendingComponent; const pendingElement = PendingComponent ? process.env.NODE_ENV !== 'production' ? renderInNonRouteComponentContext(PendingComponent, undefined, 'pendingComponent') : Vue.h(PendingComponent) : undefined; const routeErrorComponent = route?.options.errorComponent ?? router.options.defaultErrorComponent; const routeOnCatch = route?.options.onCatch ?? router.options.defaultOnCatch; const routeNotFoundComponent = route?.isRoot ? (route.options.notFoundComponent ?? router.options.notFoundRoute?.options.component) : route?.options.notFoundComponent; const ShellComponent = route?.isRoot ? route.options.shellComponent : undefined; const resolvedNoSsr = match?.ssr === false || match?.ssr === 'data-only'; const renderMatchContent = () => { const matchInner = Vue.h(MatchInner); let content = resolvedNoSsr ? Vue.h(ClientOnly, { fallback: pendingElement, }, { default: () => matchInner, }) : matchInner; // Wrap in NotFound boundary if needed if (routeNotFoundComponent) { content = Vue.h(CatchNotFound, { fallback: (error) => { error.routeId ?? (error.routeId = routeId); if (error.routeId !== routeId) { throw error; } return process.env.NODE_ENV !== 'production' ? renderInNonRouteComponentContext(routeNotFoundComponent, error, 'notFoundComponent') : Vue.h(routeNotFoundComponent, error); }, children: content, }); } // Wrap in error boundary if needed if (routeErrorComponent) { content = CatchBoundary({ getResetKey: () => activeMatch.value, errorComponent: routeErrorComponent, onCatch: (error) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { error.routeId ?? (error.routeId = routeId); throw error; } if (process.env.NODE_ENV !== 'production') { console.warn(`Warning: Error in route match: ${match?.id}`); } routeOnCatch?.(error); }, children: content, }); } if (route?.parentRoute?.id !== rootRouteId) { return content; } return Vue.h(Vue.Fragment, null, [ content, Vue.h(Vue.Fragment, null, [ (isServer ?? router.isServer) && router.options.scrollRestoration ? Vue.h(ScrollRestoration) : null, ]), ]); }; if (!ShellComponent) { return renderMatchContent(); } return Vue.h(ShellComponent, null, { // Important: return a fresh VNode on each slot invocation so that shell // components can re-render without reusing a cached VNode instance. default: () => renderMatchContent(), }); }; }, }); // On Rendered can't happen above the root layout because it actually export const MatchInner = Vue.defineComponent({ name: 'MatchInner', setup() { const router = useRouter(); // Use routeId from context (provided by parent Match) — stable string. const routeId = Vue.inject(routeIdContext); const activeMatch = useStore(router.stores.getMatchStore(routeId)); // Combined selector for match state AND remount key // This ensures both are computed in the same selector call with consistent data const combinedState = Vue.computed(() => { const match = activeMatch.value; if (!match) { // Route no longer exists - truly navigating away return null; } const matchRouteId = match.routeId; // Compute remount key const remountFn = router.routesById[matchRouteId].options.remountDeps ?? router.options.defaultRemountDeps; const remountDeps = remountFn ? remountFn({ routeId: matchRouteId, loaderDeps: match.loaderDeps, params: match._strictParams, search: match._strictSearch, }) : undefined; return [ match, remountDeps ? JSON.stringify(remountDeps) : undefined, ]; }); return () => { // If match doesn't exist, return null (component is being unmounted or not ready) const state = combinedState.value; if (!state) { return null; } const [match, remountKey] = state; const route = router.routesById[match.routeId]; // Handle different match statuses if (match.status === 'notFound') { return renderRouteNotFound(router, route, match.error); } if (match.status === 'error') { // Check if this route or any parent has an error component const RouteErrorComponent = route.options.errorComponent ?? router.options.defaultErrorComponent; // If this route has an error component, render it directly // This is more reliable than relying on Vue's error boundary if (RouteErrorComponent) { const errorProps = { error: match.error, reset: () => { router.invalidate(); }, info: { componentStack: '', }, }; return process.env.NODE_ENV !== 'production' ? renderInNonRouteComponentContext(RouteErrorComponent, errorProps, 'errorComponent') : Vue.h(RouteErrorComponent, errorProps); } // If there's no error component for this route, throw the error // so it can bubble up to the nearest parent with an error component throw match.error; } if (match.status === 'pending') { // In Vue, we render the pending component directly instead of throwing a promise // because Vue's Suspense doesn't catch thrown promises like React does const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent; if (PendingComponent) { return process.env.NODE_ENV !== 'production' ? renderInNonRouteComponentContext(PendingComponent, undefined, 'pendingComponent') : Vue.h(PendingComponent); } // If no pending component, return null while loading return null; } // Success status - render the component with remount key const Comp = route.options.component ?? router.options.defaultComponent; if (Comp) { // Pass key as a prop - Vue.h properly handles 'key' as a special prop return Vue.h(Comp, remountKey !== undefined ? { key: remountKey } : undefined); } return Vue.h(Outlet, remountKey !== undefined ? { key: remountKey } : undefined); }; }, }); export const Outlet = Vue.defineComponent({ name: 'Outlet', setup() { if (process.env.NODE_ENV !== 'production') { const nonRouteComponent = Vue.inject(nonRouteComponentContext, undefined); if (nonRouteComponent) { Vue.watch(nonRouteComponent, (component) => { console.warn(`Warning: An <Outlet /> was rendered inside a ${component}. <Outlet /> should only be rendered inside a route component.`); }, { immediate: true }); } } const router = useRouter(); const parentRouteId = Vue.inject(routeIdContext); const parentMatch = useStore(router.stores.getMatchStore(parentRouteId)); const route = router.routesById[parentRouteId]; const childMatch = useStore(router.stores.matches, (matches) => { const index = matches.findIndex((match) => match.routeId === parentRouteId); const child = matches[index + 1]; return child ? [ child.routeId, child.routeId + JSON.stringify(child._strictParams), ] : undefined; }); return () => { if (parentMatch.value?._notFound) { return renderRouteNotFound(router, route, parentMatch.value.error); } const child = childMatch.value; if (!child) { return null; } const nextMatch = Vue.h(Match, { routeId: child[0 /* routeId */], // Key based on routeId + params only (not loaderDeps) // This ensures component recreates when params change, // but NOT when only loaderDeps change key: child[1 /* key */], }); // Note: We intentionally do NOT wrap in Suspense here. // The top-level Suspense in Matches already covers the root. // The old code compared matchId (e.g. "__root__/") with rootRouteId ("__root__") // which never matched, so this Suspense was effectively dead code. // With routeId-based lookup, parentRouteId === rootRouteId would match, // causing a double-Suspense that corrupts Vue's DOM during updates. return nextMatch; }; }, }); //# sourceMappingURL=Match.jsx.map