diff --git a/.changeset/friendly-outlets-warn.md b/.changeset/friendly-outlets-warn.md
new file mode 100644
index 00000000000..d95369a3fc9
--- /dev/null
+++ b/.changeset/friendly-outlets-warn.md
@@ -0,0 +1,7 @@
+---
+'@tanstack/react-router': patch
+'@tanstack/solid-router': patch
+'@tanstack/vue-router': patch
+---
+
+Warn when an Outlet is rendered inside a pending, error, or not-found component.
diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx
index d97e49f8f5d..f459ef3fce2 100644
--- a/packages/react-router/src/CatchBoundary.tsx
+++ b/packages/react-router/src/CatchBoundary.tsx
@@ -1,6 +1,7 @@
'use client'
import * as React from 'react'
+import { wrapInNonRouteComponentContext } from './nonRouteComponentContext'
import type { ErrorRouteComponent } from './route'
import type { ErrorInfo } from 'react'
@@ -44,12 +45,21 @@ class CatchBoundaryImpl extends React.Component<{
}
render() {
const error = this.state.error
- return error
- ? React.createElement(this.props.errorComponent ?? ErrorComponent, {
+ if (error) {
+ const element = React.createElement(
+ this.props.errorComponent ?? ErrorComponent,
+ {
error,
reset: this.reset,
- })
- : this.props.children
+ },
+ )
+
+ return process.env.NODE_ENV !== 'production'
+ ? wrapInNonRouteComponentContext(element, 'errorComponent')
+ : element
+ }
+
+ return this.props.children
}
}
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index 6bca031a4dc..922c0aa5880 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -12,6 +12,10 @@ import { SafeFragment } from './SafeFragment'
import { renderRouteNotFound } from './renderRouteNotFound'
import { ScrollRestoration } from './scroll-restoration'
import { ClientOnly } from './ClientOnly'
+import {
+ nonRouteComponentContext,
+ wrapInNonRouteComponentContext,
+} from './nonRouteComponentContext'
import type {
AnyRoute,
AnyRouteMatch,
@@ -24,7 +28,14 @@ export function renderPending(
) {
const PendingComponent =
route?.options.pendingComponent ?? router.options.defaultPendingComponent
- return PendingComponent ? : null
+ if (!PendingComponent) {
+ return null
+ }
+
+ const pendingElement =
+ return process.env.NODE_ENV !== 'production'
+ ? wrapInNonRouteComponentContext(pendingElement, 'pendingComponent')
+ : pendingElement
}
type OutletMatchSelection = [
@@ -123,10 +134,16 @@ function MatchView({
throw error
}
- return React.createElement(
+ const notFoundElement = React.createElement(
routeNotFoundComponent!,
error as any,
)
+ return process.env.NODE_ENV !== 'production'
+ ? wrapInNonRouteComponentContext(
+ notFoundElement,
+ 'notFoundComponent',
+ )
+ : notFoundElement
}}
>
{resolvedNoSsr ? (
@@ -197,7 +214,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
(route.options.errorComponent ??
router.options.defaultErrorComponent) ||
ErrorComponent
- return (
+ const errorElement = (
)
+ return process.env.NODE_ENV !== 'production'
+ ? wrapInNonRouteComponentContext(errorElement, 'errorComponent')
+ : errorElement
}
throw match.error
}
@@ -220,6 +240,16 @@ export const MatchInner = React.memo(function MatchInnerImpl({
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/outletComponent
*/
export const Outlet = React.memo(function OutletImpl() {
+ if (process.env.NODE_ENV !== 'production') {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const nonRouteComponent = React.useContext(nonRouteComponentContext!)
+ if (nonRouteComponent) {
+ console.warn(
+ `Warning: An was rendered inside a ${nonRouteComponent}. should only be rendered inside a route component.`,
+ )
+ }
+ }
+
const router = useRouter()
const routeId = React.useContext(matchContext)!
diff --git a/packages/react-router/src/nonRouteComponentContext.tsx b/packages/react-router/src/nonRouteComponentContext.tsx
new file mode 100644
index 00000000000..252ab7fd109
--- /dev/null
+++ b/packages/react-router/src/nonRouteComponentContext.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import * as React from 'react'
+
+export type NonRouteComponent =
+ | 'pendingComponent'
+ | 'errorComponent'
+ | 'notFoundComponent'
+
+export const nonRouteComponentContext =
+ process.env.NODE_ENV !== 'production'
+ ? React.createContext(undefined)
+ : undefined
+
+export function wrapInNonRouteComponentContext(
+ element: React.ReactElement,
+ component: NonRouteComponent,
+): React.ReactElement {
+ const Context = nonRouteComponentContext!
+ return {element}
+}
diff --git a/packages/react-router/src/renderRouteNotFound.tsx b/packages/react-router/src/renderRouteNotFound.tsx
index f8374ebd7c2..9e363da7f44 100644
--- a/packages/react-router/src/renderRouteNotFound.tsx
+++ b/packages/react-router/src/renderRouteNotFound.tsx
@@ -1,5 +1,6 @@
import * as React from 'react'
import { DefaultGlobalNotFound } from './not-found'
+import { wrapInNonRouteComponentContext } from './nonRouteComponentContext'
import type { AnyRoute, AnyRouter } from '@tanstack/router-core'
/**
@@ -17,7 +18,12 @@ export function renderRouteNotFound(
) {
if (!route.options.notFoundComponent) {
if (router.options.defaultNotFoundComponent) {
- return
+ const notFoundElement = (
+
+ )
+ return process.env.NODE_ENV !== 'production'
+ ? wrapInNonRouteComponentContext(notFoundElement, 'notFoundComponent')
+ : notFoundElement
}
if (process.env.NODE_ENV !== 'production') {
@@ -31,5 +37,8 @@ export function renderRouteNotFound(
return
}
- return
+ const notFoundElement =
+ return process.env.NODE_ENV !== 'production'
+ ? wrapInNonRouteComponentContext(notFoundElement, 'notFoundComponent')
+ : notFoundElement
}
diff --git a/packages/react-router/tests/Outlet.test.tsx b/packages/react-router/tests/Outlet.test.tsx
new file mode 100644
index 00000000000..e8dc5447cbc
--- /dev/null
+++ b/packages/react-router/tests/Outlet.test.tsx
@@ -0,0 +1,146 @@
+import { afterEach, expect, test, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import { createMemoryHistory } from '@tanstack/history'
+import { createControlledPromise, notFound } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+const outletWarning = (
+ component: 'pendingComponent' | 'errorComponent' | 'notFoundComponent',
+) =>
+ `Warning: An was rendered inside a ${component}. should only be rendered inside a route component.`
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
+
+test('does not warn when Outlet is rendered inside a route component', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+ Root route
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Index route')).toBeInTheDocument()
+ expect(warn).not.toHaveBeenCalled()
+})
+
+test('warns when Outlet is rendered inside a pendingComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const pending = createControlledPromise()
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const pendingRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/pending',
+ loader: () => pending,
+ pendingMs: 0,
+ pendingComponent: () => (
+ <>
+ Pending route
+
+ >
+ ),
+ component: () => Resolved route,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pendingRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ await screen.findByText('Index route')
+
+ const navigation = router.navigate({ to: '/pending' })
+ expect(await screen.findByText('Pending route')).toBeInTheDocument()
+ pending.resolve()
+ await navigation
+
+ expect(warn).toHaveBeenCalledWith(outletWarning('pendingComponent'))
+})
+
+test('warns when Outlet is rendered inside an errorComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: () => {
+ throw new Error('Loader failed')
+ },
+ errorComponent: () => (
+ <>
+ Error route
+
+ >
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Error route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent'))
+})
+
+test('warns when Outlet is rendered inside a notFoundComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const notFoundRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/not-found',
+ component: () => {
+ throw notFound()
+ },
+ notFoundComponent: () => (
+ <>
+ Not found route
+
+ >
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, notFoundRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ await screen.findByText('Index route')
+ await router.navigate({ to: '/not-found' })
+
+ expect(await screen.findByText('Not found route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('notFoundComponent'))
+})
diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx
index 12e44465812..a69c4859238 100644
--- a/packages/solid-router/src/CatchBoundary.tsx
+++ b/packages/solid-router/src/CatchBoundary.tsx
@@ -1,5 +1,6 @@
import * as Solid from 'solid-js'
import { Dynamic } from 'solid-js/web'
+import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type { ErrorRouteComponent } from './route'
export function CatchBoundary(
@@ -19,7 +20,18 @@ export function CatchBoundary(
Solid.on(props.getResetKey, () => reset(), { defer: true }),
)
- return (
+ return process.env.NODE_ENV !== 'production' ? (
+ renderInNonRouteComponentContext(
+ () => (
+
+ ),
+ 'errorComponent',
+ )
+ ) : (
{
const MatchContent = () => (
}
+ fallback={(() => {
+ if (process.env.NODE_ENV !== 'production') {
+ return renderInNonRouteComponentContext(
+ () => ,
+ 'pendingComponent',
+ )
+ }
+ return
+ })()}
>
@@ -86,13 +98,20 @@ export const Match = (props: { routeId: string }) => {
{
// Data-only SSR renders the inner fallback on the server, so
// avoid adding an extra suspense fallback on the client.
- shouldSkipSuspenseFallback() ? undefined : (
-
- )
- }
+ if (shouldSkipSuspenseFallback()) {
+ return undefined
+ }
+ if (process.env.NODE_ENV !== 'production') {
+ return renderInNonRouteComponentContext(
+ () => ,
+ 'pendingComponent',
+ )
+ }
+ return
+ })()}
>
{
throw notFoundError
}
- return (
+ return process.env.NODE_ENV !== 'production' ? (
+ renderInNonRouteComponentContext(
+ () => (
+
+ ),
+ 'notFoundComponent',
+ )
+ ) : (
{
}
+ fallback={(() => {
+ if (process.env.NODE_ENV !== 'production') {
+ return renderInNonRouteComponentContext(
+ () => (
+
+ ),
+ 'pendingComponent',
+ )
+ }
+ return
+ })()}
>
@@ -204,7 +243,19 @@ export const MatchInner = (): any => {
router.options.defaultErrorComponent) ||
ErrorComponent
- return (
+ return process.env.NODE_ENV !== 'production' ? (
+ renderInNonRouteComponentContext(
+ () => (
+
+ ),
+ 'errorComponent',
+ )
+ ) : (
{
}
export const Outlet = () => {
+ if (process.env.NODE_ENV !== 'production') {
+ const nonRouteComponent = Solid.useContext(nonRouteComponentContext!)
+ if (nonRouteComponent) {
+ console.warn(
+ `Warning: An was rendered inside a ${nonRouteComponent}. should only be rendered inside a route component.`,
+ )
+ }
+ }
+
const router = useRouter()
const nearestParentMatch = Solid.useContext(nearestMatchContext)
const parentMatch = nearestParentMatch[1 /* match */]
@@ -256,9 +316,21 @@ export const Outlet = () => {
fallback={}
>
- }
+ fallback={(() => {
+ if (process.env.NODE_ENV !== 'production') {
+ return renderInNonRouteComponentContext(
+ () => (
+
+ ),
+ 'pendingComponent',
+ )
+ }
+ return (
+
+ )
+ })()}
>
diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx
index 704e5026f4c..d2dd1a681ad 100644
--- a/packages/solid-router/src/Matches.tsx
+++ b/packages/solid-router/src/Matches.tsx
@@ -7,6 +7,7 @@ import { Rendered, Transitioner } from './Transitioner'
import { nearestMatchContext } from './matchContext'
import { SafeFragment } from './SafeFragment'
import { Match } from './Match'
+import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type {
AnyRoute,
AnyRouter,
@@ -51,7 +52,19 @@ export function Matches() {
return (
: null}
+ fallback={
+ PendingComponent
+ ? (() => {
+ if (process.env.NODE_ENV !== 'production') {
+ return renderInNonRouteComponentContext(
+ () => ,
+ 'pendingComponent',
+ )
+ }
+ return
+ })()
+ : null
+ }
>
diff --git a/packages/solid-router/src/nonRouteComponentContext.tsx b/packages/solid-router/src/nonRouteComponentContext.tsx
new file mode 100644
index 00000000000..908eec02b54
--- /dev/null
+++ b/packages/solid-router/src/nonRouteComponentContext.tsx
@@ -0,0 +1,19 @@
+import * as Solid from 'solid-js'
+
+export type NonRouteComponent =
+ | 'pendingComponent'
+ | 'errorComponent'
+ | 'notFoundComponent'
+
+export const nonRouteComponentContext =
+ process.env.NODE_ENV !== 'production'
+ ? /* @__PURE__ */ Solid.createContext()
+ : undefined
+
+export function renderInNonRouteComponentContext(
+ render: () => Solid.JSX.Element,
+ component: NonRouteComponent,
+) {
+ const Context = nonRouteComponentContext!
+ return {render()}
+}
diff --git a/packages/solid-router/src/renderRouteNotFound.tsx b/packages/solid-router/src/renderRouteNotFound.tsx
index 078a632e08c..9e8a09e9c7a 100644
--- a/packages/solid-router/src/renderRouteNotFound.tsx
+++ b/packages/solid-router/src/renderRouteNotFound.tsx
@@ -1,4 +1,5 @@
import { DefaultGlobalNotFound } from './not-found'
+import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type { AnyRoute, AnyRouter } from '@tanstack/router-core'
/**
@@ -14,19 +15,35 @@ export function renderRouteNotFound(
route: AnyRoute,
data: any,
) {
+ if (process.env.NODE_ENV !== 'production') {
+ if (!route.options.notFoundComponent) {
+ if (router.options.defaultNotFoundComponent) {
+ const DefaultNotFoundComponent = router.options.defaultNotFoundComponent
+ return renderInNonRouteComponentContext(
+ () => ,
+ 'notFoundComponent',
+ )
+ }
+
+ console.warn(
+ `Warning: A notFoundError was encountered on the route with ID "${route.id}", but a notFoundComponent option was not configured, nor was a router level defaultNotFoundComponent configured. Consider configuring at least one of these to avoid TanStack Router's overly generic defaultNotFoundComponent (Not Found
)`,
+ )
+
+ return
+ }
+
+ const NotFoundComponent = route.options.notFoundComponent
+ return renderInNonRouteComponentContext(
+ () => ,
+ 'notFoundComponent',
+ )
+ }
+
if (!route.options.notFoundComponent) {
if (router.options.defaultNotFoundComponent) {
return
}
- if (process.env.NODE_ENV !== 'production') {
- if (!route.options.notFoundComponent) {
- console.warn(
- `Warning: A notFoundError was encountered on the route with ID "${route.id}", but a notFoundComponent option was not configured, nor was a router level defaultNotFoundComponent configured. Consider configuring at least one of these to avoid TanStack Router's overly generic defaultNotFoundComponent (Not Found
)`,
- )
- }
- }
-
return
}
diff --git a/packages/solid-router/tests/Outlet.test.tsx b/packages/solid-router/tests/Outlet.test.tsx
new file mode 100644
index 00000000000..248c710b1c3
--- /dev/null
+++ b/packages/solid-router/tests/Outlet.test.tsx
@@ -0,0 +1,146 @@
+import { afterEach, expect, test, vi } from 'vitest'
+import { cleanup, render, screen } from '@solidjs/testing-library'
+import { createMemoryHistory } from '@tanstack/history'
+import { createControlledPromise, notFound } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+const outletWarning = (
+ component: 'pendingComponent' | 'errorComponent' | 'notFoundComponent',
+) =>
+ `Warning: An was rendered inside a ${component}. should only be rendered inside a route component.`
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
+
+test('does not warn when Outlet is rendered inside a route component', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+ Root route
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+
+ expect(await screen.findByText('Index route')).toBeInTheDocument()
+ expect(warn).not.toHaveBeenCalled()
+})
+
+test('warns when Outlet is rendered inside a pendingComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const pending = createControlledPromise()
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const pendingRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/pending',
+ loader: () => pending,
+ pendingMs: 0,
+ pendingComponent: () => (
+ <>
+ Pending route
+
+ >
+ ),
+ component: () => Resolved route,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pendingRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+ await screen.findByText('Index route')
+
+ const navigation = router.navigate({ to: '/pending' })
+ expect(await screen.findByText('Pending route')).toBeInTheDocument()
+ pending.resolve()
+ await navigation
+
+ expect(warn).toHaveBeenCalledWith(outletWarning('pendingComponent'))
+})
+
+test('warns when Outlet is rendered inside an errorComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: () => {
+ throw new Error('Loader failed')
+ },
+ errorComponent: () => (
+ <>
+ Error route
+
+ >
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+
+ expect(await screen.findByText('Error route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent'))
+})
+
+test('warns when Outlet is rendered inside a notFoundComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const notFoundRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/not-found',
+ component: () => {
+ throw notFound()
+ },
+ notFoundComponent: () => (
+ <>
+ Not found route
+
+ >
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, notFoundRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+ await screen.findByText('Index route')
+ await router.navigate({ to: '/not-found' })
+
+ expect(await screen.findByText('Not found route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('notFoundComponent'))
+})
diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx
index e9124451586..d170e6fb1a1 100644
--- a/packages/vue-router/src/CatchBoundary.tsx
+++ b/packages/vue-router/src/CatchBoundary.tsx
@@ -1,4 +1,5 @@
import * as Vue from 'vue'
+import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type { ErrorRouteComponent } from './route'
type CatchBoundaryProps = {
@@ -49,13 +50,25 @@ const VueErrorBoundary = Vue.defineComponent({
return false
})
- return () =>
- error.value
- ? Vue.h(props.errorComponent ?? ErrorComponent, {
- error: error.value,
- reset,
- })
- : (props.children as Vue.VNode)
+ return () => {
+ if (!error.value) {
+ return props.children as Vue.VNode
+ }
+
+ const errorComponent = props.errorComponent ?? ErrorComponent
+ const errorProps = {
+ error: error.value,
+ reset,
+ }
+
+ return process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ errorComponent,
+ errorProps,
+ 'errorComponent',
+ )
+ : Vue.h(errorComponent, errorProps)
+ }
},
})
diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx
index 96d81970a51..2f4cd34074b 100644
--- a/packages/vue-router/src/Match.tsx
+++ b/packages/vue-router/src/Match.tsx
@@ -9,6 +9,10 @@ import { CatchNotFound } from './not-found'
import { routeIdContext } from './matchContext'
import { renderRouteNotFound } from './renderRouteNotFound'
import { ScrollRestoration } from './scroll-restoration'
+import {
+ nonRouteComponentContext,
+ renderInNonRouteComponentContext,
+} from './nonRouteComponentContext'
import type { VNode } from 'vue'
import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core'
@@ -41,7 +45,13 @@ export const Match = Vue.defineComponent({
route?.options.pendingComponent ??
router.options.defaultPendingComponent
const pendingElement = PendingComponent
- ? Vue.h(PendingComponent)
+ ? process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ PendingComponent,
+ undefined,
+ 'pendingComponent',
+ )
+ : Vue.h(PendingComponent)
: undefined
const routeErrorComponent =
route?.options.errorComponent ?? router.options.defaultErrorComponent
@@ -81,7 +91,13 @@ export const Match = Vue.defineComponent({
throw error
}
- return Vue.h(routeNotFoundComponent, error)
+ return process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ routeNotFoundComponent,
+ error,
+ 'notFoundComponent',
+ )
+ : Vue.h(routeNotFoundComponent, error)
},
children: content,
})
@@ -197,7 +213,7 @@ export const MatchInner = Vue.defineComponent({
// 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, {
+ const errorProps = {
error: match.error,
reset: () => {
router.invalidate()
@@ -205,7 +221,14 @@ export const MatchInner = Vue.defineComponent({
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
@@ -221,7 +244,13 @@ export const MatchInner = Vue.defineComponent({
router.options.defaultPendingComponent
if (PendingComponent) {
- return Vue.h(PendingComponent)
+ return process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ PendingComponent,
+ undefined,
+ 'pendingComponent',
+ )
+ : Vue.h(PendingComponent)
}
// If no pending component, return null while loading
@@ -249,6 +278,21 @@ export const MatchInner = Vue.defineComponent({
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 was rendered inside a ${component}. should only be rendered inside a route component.`,
+ )
+ },
+ { immediate: true },
+ )
+ }
+ }
+
const router = useRouter()
const parentRouteId = Vue.inject(routeIdContext)!
diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx
index 15799d2a5ba..ae3b4f72eec 100644
--- a/packages/vue-router/src/Matches.tsx
+++ b/packages/vue-router/src/Matches.tsx
@@ -6,6 +6,7 @@ import { useRouter } from './useRouter'
import { useTransitionerSetup } from './Transitioner'
import { routeIdContext } from './matchContext'
import { Match } from './Match'
+import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type {
AnyRouter,
DeepPartial,
@@ -40,7 +41,13 @@ export const Matches = Vue.defineComponent({
return () => {
const pendingElement = router.options.defaultPendingComponent
- ? Vue.h(router.options.defaultPendingComponent)
+ ? process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ router.options.defaultPendingComponent,
+ undefined,
+ 'pendingComponent',
+ )
+ : Vue.h(router.options.defaultPendingComponent)
: null
// Do not render a root Suspense during SSR or hydrating from SSR
diff --git a/packages/vue-router/src/nonRouteComponentContext.tsx b/packages/vue-router/src/nonRouteComponentContext.tsx
new file mode 100644
index 00000000000..2111a9ef570
--- /dev/null
+++ b/packages/vue-router/src/nonRouteComponentContext.tsx
@@ -0,0 +1,45 @@
+import * as Vue from 'vue'
+
+export type NonRouteComponent =
+ | 'pendingComponent'
+ | 'errorComponent'
+ | 'notFoundComponent'
+
+export const nonRouteComponentContext =
+ process.env.NODE_ENV !== 'production'
+ ? (Symbol('nonRouteComponentContext') as Vue.InjectionKey<
+ Vue.ComputedRef
+ >)
+ : undefined
+
+const NonRouteComponentContextProvider =
+ process.env.NODE_ENV !== 'production'
+ ? Vue.defineComponent({
+ name: 'NonRouteComponentContextProvider',
+ props: {
+ value: {
+ type: String as Vue.PropType,
+ required: true,
+ },
+ },
+ setup(props, { slots }) {
+ Vue.provide(
+ nonRouteComponentContext!,
+ Vue.computed(() => props.value),
+ )
+ return () => slots.default?.()
+ },
+ })
+ : undefined
+
+export function renderInNonRouteComponentContext(
+ component: Vue.Component,
+ props: Record | undefined,
+ context: NonRouteComponent,
+): Vue.VNode {
+ return Vue.h(
+ NonRouteComponentContextProvider!,
+ { value: context },
+ { default: () => Vue.h(component, props) },
+ )
+}
diff --git a/packages/vue-router/src/renderRouteNotFound.tsx b/packages/vue-router/src/renderRouteNotFound.tsx
index 3b029070a9c..104a65ab730 100644
--- a/packages/vue-router/src/renderRouteNotFound.tsx
+++ b/packages/vue-router/src/renderRouteNotFound.tsx
@@ -1,5 +1,6 @@
import * as Vue from 'vue'
import { DefaultGlobalNotFound } from './not-found'
+import { renderInNonRouteComponentContext } from './nonRouteComponentContext'
import type { AnyRoute, AnyRouter } from '@tanstack/router-core'
/**
@@ -17,7 +18,13 @@ export function renderRouteNotFound(
): Vue.VNode {
if (!route.options.notFoundComponent) {
if (router.options.defaultNotFoundComponent) {
- return Vue.h(router.options.defaultNotFoundComponent, data)
+ return process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ router.options.defaultNotFoundComponent,
+ data,
+ 'notFoundComponent',
+ )
+ : Vue.h(router.options.defaultNotFoundComponent, data)
}
if (process.env.NODE_ENV !== 'production') {
@@ -31,5 +38,11 @@ export function renderRouteNotFound(
return Vue.h(DefaultGlobalNotFound)
}
- return Vue.h(route.options.notFoundComponent, data)
+ return process.env.NODE_ENV !== 'production'
+ ? renderInNonRouteComponentContext(
+ route.options.notFoundComponent,
+ data,
+ 'notFoundComponent',
+ )
+ : Vue.h(route.options.notFoundComponent, data)
}
diff --git a/packages/vue-router/tests/Outlet.test.tsx b/packages/vue-router/tests/Outlet.test.tsx
new file mode 100644
index 00000000000..f8d0f699a2d
--- /dev/null
+++ b/packages/vue-router/tests/Outlet.test.tsx
@@ -0,0 +1,187 @@
+import { afterEach, expect, test, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/vue'
+import { createMemoryHistory } from '@tanstack/history'
+import { createControlledPromise, notFound } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+const outletWarning = (
+ component: 'pendingComponent' | 'errorComponent' | 'notFoundComponent',
+) =>
+ `Warning: An was rendered inside a ${component}. should only be rendered inside a route component.`
+
+afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+})
+
+test('does not warn when Outlet is rendered inside a route component', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+ Root route
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Index route')).toBeInTheDocument()
+ expect(warn).not.toHaveBeenCalled()
+})
+
+test('warns when Outlet is rendered inside a pendingComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const pending = createControlledPromise()
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const pendingRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/pending',
+ loader: () => pending,
+ pendingMs: 0,
+ pendingComponent: () => (
+ <>
+ Pending route
+
+ >
+ ),
+ component: () => Resolved route,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pendingRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ await screen.findByText('Index route')
+
+ const navigation = router.navigate({ to: '/pending' })
+ expect(await screen.findByText('Pending route')).toBeInTheDocument()
+ pending.resolve()
+ await navigation
+
+ expect(warn).toHaveBeenCalledWith(outletWarning('pendingComponent'))
+})
+
+test('warns when Outlet is rendered inside an errorComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ loader: () => {
+ throw new Error('Loader failed')
+ },
+ errorComponent: () => (
+ <>
+ Error route
+
+ >
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ expect(await screen.findByText('Error route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent'))
+})
+
+test('warns with the current component after a fallback transition', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const pending = createControlledPromise()
+ const FallbackComponent = (props: { error?: Error }) => (
+ <>
+ {props.error ? 'Error route' : 'Pending route'}
+
+ >
+ )
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const transitionRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/transition',
+ loader: () => pending,
+ pendingMs: 0,
+ pendingComponent: FallbackComponent,
+ errorComponent: FallbackComponent,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, transitionRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ await screen.findByText('Index route')
+
+ const navigation = router.navigate({ to: '/transition' })
+ expect(await screen.findByText('Pending route')).toBeInTheDocument()
+ pending.reject(new Error('Loader failed'))
+ await navigation
+
+ expect(await screen.findByText('Error route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('pendingComponent'))
+ expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent'))
+})
+
+test('warns when Outlet is rendered inside a notFoundComponent', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index route,
+ })
+ const notFoundRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/not-found',
+ component: () => {
+ throw notFound()
+ },
+ notFoundComponent: () => (
+ <>
+ Not found route
+
+ >
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, notFoundRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ await screen.findByText('Index route')
+ await router.navigate({ to: '/not-found' })
+
+ expect(await screen.findByText('Not found route')).toBeInTheDocument()
+ expect(warn).toHaveBeenCalledWith(outletWarning('notFoundComponent'))
+})