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')) +})