UNPKG

@tanstack/vue-router

Version:

Modern and scalable routing for Vue applications

396 lines 19.8 kB
import * as Vue from 'vue'; import { createControlledPromise, getLocationChangeInfo, invariant, isNotFound, isRedirect, rootRouteId, } from '@tanstack/router-core'; import { isServer } from '@tanstack/router-core/isServer'; import { useStore } from '@tanstack/vue-store'; import { CatchBoundary, ErrorComponent } from './CatchBoundary'; import { ClientOnly } from './ClientOnly'; import { useRouter } from './useRouter'; import { CatchNotFound } from './not-found'; import { matchContext, pendingMatchContext, routeIdContext, } from './matchContext'; import { renderRouteNotFound } from './renderRouteNotFound'; import { ScrollRestoration } from './scroll-restoration'; export const Match = Vue.defineComponent({ name: 'Match', props: { matchId: { type: String, required: true, }, }, setup(props) { const router = useRouter(); // Derive routeId from initial props.matchId — stable for this component's // lifetime. The routeId never changes for a given route position in the // tree, even when matchId changes (loaderDepsHash, etc). const routeId = router.stores.matchStores.get(props.matchId)?.routeId; if (!routeId) { if (process.env.NODE_ENV !== 'production') { throw new Error(`Invariant failed: Could not find routeId for matchId "${props.matchId}". Please file an issue!`); } invariant(); } // Static route-tree check: is this route a direct child of the root? // parentRoute is set at build time, so no reactive tracking needed. const isChildOfRoot = router.routesById[routeId]?.parentRoute?.id === rootRouteId; // Single stable store subscription — getRouteMatchStore returns a // cached computed store that resolves routeId → current match state // through the signal graph. No bridge needed. const activeMatch = useStore(router.stores.getRouteMatchStore(routeId), (value) => value); const isPendingMatchRef = useStore(router.stores.pendingRouteIds, (pendingRouteIds) => Boolean(pendingRouteIds[routeId]), { equal: Object.is }); const loadedAt = useStore(router.stores.loadedAt, (value) => value); const matchData = Vue.computed(() => { const match = activeMatch.value; if (!match) { return null; } return { matchId: match.id, routeId, loadedAt: loadedAt.value, ssr: match.ssr, _displayPending: match._displayPending, }; }); const route = Vue.computed(() => matchData.value ? router.routesById[matchData.value.routeId] : null); const PendingComponent = Vue.computed(() => route.value?.options?.pendingComponent ?? router?.options?.defaultPendingComponent); const pendingElement = Vue.computed(() => PendingComponent.value ? Vue.h(PendingComponent.value) : undefined); const routeErrorComponent = Vue.computed(() => route.value?.options?.errorComponent ?? router?.options?.defaultErrorComponent); const routeOnCatch = Vue.computed(() => route.value?.options?.onCatch ?? router?.options?.defaultOnCatch); const routeNotFoundComponent = Vue.computed(() => route.value?.isRoot ? // If it's the root route, use the globalNotFound option, with fallback to the notFoundRoute's component (route.value?.options?.notFoundComponent ?? router?.options?.notFoundRoute?.options?.component) : route.value?.options?.notFoundComponent); const hasShellComponent = Vue.computed(() => { if (!route.value?.isRoot) return false; return !!route.value.options.shellComponent; }); const ShellComponent = Vue.computed(() => hasShellComponent.value ? route.value.options.shellComponent : null); // Provide routeId context (stable string) for children. // MatchInner, Outlet, and useMatch all consume this. Vue.provide(routeIdContext, routeId); // Provide reactive nearest-match context for hooks that slice the active // matches array relative to the current match. const matchIdRef = Vue.computed(() => activeMatch.value?.id ?? props.matchId); Vue.provide(matchContext, matchIdRef); Vue.provide(pendingMatchContext, isPendingMatchRef); return () => { const actualMatchId = matchData.value?.matchId ?? props.matchId; const resolvedNoSsr = matchData.value?.ssr === false || matchData.value?.ssr === 'data-only'; const shouldClientOnly = resolvedNoSsr || !!matchData.value?._displayPending; const renderMatchContent = () => { const matchInner = Vue.h(MatchInner, { matchId: actualMatchId }); let content = shouldClientOnly ? Vue.h(ClientOnly, { fallback: pendingElement.value, }, { default: () => matchInner, }) : matchInner; // Wrap in NotFound boundary if needed if (routeNotFoundComponent.value) { content = Vue.h(CatchNotFound, { fallback: (error) => { error.routeId ?? (error.routeId = matchData.value?.routeId); // 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.value || (error.routeId && error.routeId !== matchData.value?.routeId) || (!error.routeId && route.value && !route.value.isRoot)) throw error; return Vue.h(routeNotFoundComponent.value, error); }, children: content, }); } // Wrap in error boundary if needed if (routeErrorComponent.value) { content = CatchBoundary({ getResetKey: () => matchData.value?.loadedAt ?? 0, errorComponent: routeErrorComponent.value || ErrorComponent, onCatch: (error) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { error.routeId ?? (error.routeId = matchData.value?.routeId); throw error; } if (process.env.NODE_ENV !== 'production') { console.warn(`Warning: Error in route match: ${actualMatchId}`); } routeOnCatch.value?.(error); }, children: content, }); } // Add scroll restoration if needed const withScrollRestoration = [ content, isChildOfRoot ? Vue.h(Vue.Fragment, null, [ Vue.h(OnRendered), router.options.scrollRestoration && (isServer ?? router.isServer) ? Vue.h(ScrollRestoration) : null, ]) : null, ].filter(Boolean); // Return single child directly to avoid Fragment wrapper that causes hydration mismatch if (withScrollRestoration.length === 1) { return withScrollRestoration[0]; } return Vue.h(Vue.Fragment, null, withScrollRestoration); }; if (!hasShellComponent.value) { return renderMatchContent(); } return Vue.h(ShellComponent.value, 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 // renders a dummy dom element to track the rendered state of the app. // We render a script tag with a key that changes based on the current // location state.__TSR_key. Also, because it's below the root layout, it // allows us to fire onRendered events even after a hydration mismatch // error that occurred above the root layout (like bad head/link tags, // which is common). const OnRendered = Vue.defineComponent({ name: 'OnRendered', setup() { const router = useRouter(); const location = useStore(router.stores.resolvedLocation, (resolvedLocation) => resolvedLocation?.state.__TSR_key); let prevHref; Vue.watch(location, () => { if (location.value) { const currentHref = router.latestLocation.href; if (prevHref === undefined || prevHref !== currentHref) { router.emit({ type: 'onRendered', ...getLocationChangeInfo(router.stores.location.get(), router.stores.resolvedLocation.get()), }); prevHref = currentHref; } } }, { immediate: true }); return () => null; }, }); export const MatchInner = Vue.defineComponent({ name: 'MatchInner', props: { matchId: { type: String, required: true, }, }, setup(props) { const router = useRouter(); // Use routeId from context (provided by parent Match) — stable string. const routeId = Vue.inject(routeIdContext); const activeMatch = useStore(router.stores.getRouteMatchStore(routeId), (value) => value); // 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; let remountKey; if (remountFn) { const remountDeps = remountFn({ routeId: matchRouteId, loaderDeps: match.loaderDeps, params: match._strictParams, search: match._strictSearch, }); remountKey = remountDeps ? JSON.stringify(remountDeps) : undefined; } return { routeId: matchRouteId, match: { id: match.id, status: match.status, error: match.error, ssr: match.ssr, _forcePending: match._forcePending, _displayPending: match._displayPending, _nonReactive: match._nonReactive, }, remountKey, }; }); const route = Vue.computed(() => { if (!combinedState.value) return null; return router.routesById[combinedState.value.routeId]; }); const match = Vue.computed(() => combinedState.value?.match); const remountKey = Vue.computed(() => combinedState.value?.remountKey); const getMatchPromise = (match, key) => { return (router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]); }; return () => { // If match doesn't exist, return null (component is being unmounted or not ready) if (!combinedState.value || !match.value || !route.value) return null; // Handle different match statuses if (match.value._displayPending) { const PendingComponent = route.value.options.pendingComponent ?? router.options.defaultPendingComponent; return PendingComponent ? Vue.h(PendingComponent) : null; } if (match.value._forcePending) { const PendingComponent = route.value.options.pendingComponent ?? router.options.defaultPendingComponent; return PendingComponent ? Vue.h(PendingComponent) : null; } if (match.value.status === 'notFound') { if (!isNotFound(match.value.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a notFound error'); } invariant(); } return renderRouteNotFound(router, route.value, match.value.error); } if (match.value.status === 'redirected') { if (!isRedirect(match.value.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a redirect error'); } invariant(); } throw getMatchPromise(match.value, 'loadPromise'); } if (match.value.status === 'error') { // Check if this route or any parent has an error component const RouteErrorComponent = route.value.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) { return Vue.h(RouteErrorComponent, { error: match.value.error, reset: () => { router.invalidate(); }, info: { componentStack: '', }, }); } // 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.value.error; } if (match.value.status === 'pending') { const pendingMinMs = route.value.options.pendingMinMs ?? router.options.defaultPendingMinMs; const routerMatch = router.getMatch(match.value.id); if (pendingMinMs && 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); } } // 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.value.options.pendingComponent ?? router.options.defaultPendingComponent; if (PendingComponent) { return Vue.h(PendingComponent); } // If no pending component, return null while loading return null; } // Success status - render the component with remount key const Comp = route.value.options.component ?? router.options.defaultComponent; const key = remountKey.value; if (Comp) { // Pass key as a prop - Vue.h properly handles 'key' as a special prop return Vue.h(Comp, key !== undefined ? { key } : undefined); } return Vue.h(Outlet, key !== undefined ? { key } : undefined); }; }, }); export const Outlet = Vue.defineComponent({ name: 'Outlet', setup() { const router = useRouter(); const parentRouteId = Vue.inject(routeIdContext); if (!parentRouteId) { return () => null; } // Parent state via stable routeId store — single subscription const parentMatch = useStore(router.stores.getRouteMatchStore(parentRouteId), (v) => v); const route = Vue.computed(() => parentMatch.value ? router.routesById[parentMatch.value.routeId] : undefined); const parentGlobalNotFound = Vue.computed(() => parentMatch.value?.globalNotFound ?? false); // Child match lookup: read the child matchId from the shared derived // map (one reactive node for the whole tree), then grab match state // directly from the pool. const childMatchIdMap = useStore(router.stores.childMatchIdByRouteId, (v) => v); const childMatchData = Vue.computed(() => { const childId = childMatchIdMap.value[parentRouteId]; if (!childId) return null; const child = router.stores.matchStores.get(childId)?.get(); if (!child) return null; return { id: child.id, // Key based on routeId + params only (not loaderDeps) // This ensures component recreates when params change, // but NOT when only loaderDeps change paramsKey: child.routeId + JSON.stringify(child._strictParams), }; }); return () => { if (parentGlobalNotFound.value) { if (!route.value) { return null; } return renderRouteNotFound(router, route.value, undefined); } if (!childMatchData.value) { return null; } const nextMatch = Vue.h(Match, { matchId: childMatchData.value.id, key: childMatchData.value.paramsKey, }); // 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