@tanstack/vue-router
Version:
Modern and scalable routing for Vue applications
255 lines • 12.3 kB
JSX
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