diff --git a/packages/nuxt/src/app/components/layout.ts b/packages/nuxt/src/app/components/layout.ts
index 4a307ca4636..c578a6e9a20 100644
--- a/packages/nuxt/src/app/components/layout.ts
+++ b/packages/nuxt/src/app/components/layout.ts
@@ -1,4 +1,4 @@
-import { defineComponent, isRef, Ref, Transition } from 'vue'
+import { defineComponent, isRef, nextTick, onMounted, Ref, Transition, VNode } from 'vue'
import { _wrapIf } from './utils'
import { useRoute } from '#app'
// @ts-ignore
@@ -16,6 +16,18 @@ export default defineComponent({
setup (props, context) {
const route = useRoute()
+ let vnode: VNode
+ let _layout: string | false
+ if (process.dev && process.client) {
+ onMounted(() => {
+ nextTick(() => {
+ if (_layout && ['#comment', '#text'].includes(vnode?.el?.nodeName)) {
+ console.warn(`[nuxt] \`${_layout}\` layout does not have a single root node and will cause errors when navigating between routes.`)
+ }
+ })
+ })
+ }
+
return () => {
const layout = (isRef(props.name) ? props.name.value : props.name) ?? route.meta.layout as string ?? 'default'
@@ -24,10 +36,20 @@ export default defineComponent({
console.warn(`Invalid layout \`${layout}\` selected.`)
}
+ const transitionProps = route.meta.layoutTransition ?? defaultLayoutTransition
+
// We avoid rendering layout transition if there is no layout to render
- return _wrapIf(Transition, hasLayout && (route.meta.layoutTransition ?? defaultLayoutTransition),
- _wrapIf(layouts[layout], hasLayout, context.slots)
- ).default()
+ return _wrapIf(Transition, hasLayout && transitionProps, {
+ default: () => {
+ if (process.dev && process.client && transitionProps) {
+ _layout = layout
+ vnode = _wrapIf(layouts[layout], hasLayout, context.slots).default()
+ return vnode
+ }
+
+ return _wrapIf(layouts[layout], hasLayout, context.slots).default()
+ }
+ }).default()
}
}
})
diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts
index 49e223df6f5..9d394a25313 100644
--- a/packages/nuxt/src/pages/runtime/page.ts
+++ b/packages/nuxt/src/pages/runtime/page.ts
@@ -1,5 +1,7 @@
-import { computed, DefineComponent, defineComponent, h, inject, provide, reactive, Suspense, Transition } from 'vue'
-import { RouteLocation, RouteLocationNormalized, RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
+import { computed, defineComponent, h, inject, provide, reactive, onMounted, nextTick, Suspense, Transition } from 'vue'
+import type { DefineComponent, VNode } from 'vue'
+import { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
+import type { RouteLocation } from 'vue-router'
import { generateRouteKey, RouterViewSlotProps, wrapInKeepAlive } from './utils'
import { useNuxtApp } from '#app'
@@ -34,15 +36,16 @@ export default defineComponent({
if (!routeProps.Component) { return }
const key = generateRouteKey(props.pageKey, routeProps)
+ const transitionProps = routeProps.route.meta.pageTransition ?? defaultPageTransition
- return _wrapIf(Transition, routeProps.route.meta.pageTransition ?? defaultPageTransition,
+ return _wrapIf(Transition, transitionProps,
wrapInKeepAlive(routeProps.route.meta.keepalive, isNested && nuxtApp.isHydrating
// Include route children in parent suspense
- ? h(Component, { key, routeProps, pageKey: key } as {})
+ ? h(Component, { key, routeProps, pageKey: key, hasTransition: !!transitionProps } as {})
: h(Suspense, {
onPending: () => nuxtApp.callHook('page:start', routeProps.Component),
onResolve: () => nuxtApp.callHook('page:finish', routeProps.Component)
- }, { default: () => h(Component, { key, routeProps, pageKey: key } as {}) })
+ }, { default: () => h(Component, { key, routeProps, pageKey: key, hasTransition: !!transitionProps } as {}) })
)).default()
}
})
@@ -60,7 +63,7 @@ const defaultPageTransition = { name: 'page', mode: 'out-in' }
const Component = defineComponent({
// TODO: Type props
// eslint-disable-next-line vue/require-prop-types
- props: ['routeProps', 'pageKey'],
+ props: ['routeProps', 'pageKey', 'hasTransition'],
setup (props) {
// Prevent reactivity when the page will be rerendered in a different suspense fork
const previousKey = props.pageKey
@@ -73,6 +76,26 @@ const Component = defineComponent({
}
provide('_route', reactive(route))
- return () => h(props.routeProps.Component)
+
+ let vnode: VNode
+ if (process.dev && process.client && props.hasTransition) {
+ onMounted(() => {
+ nextTick(() => {
+ if (['#comment', '#text'].includes(vnode?.el?.nodeName)) {
+ const filename = (vnode?.type as any).__file
+ console.warn(`[nuxt] \`${filename}\` does not have a single root node and will cause errors when navigating between routes.`)
+ }
+ })
+ })
+ }
+
+ return () => {
+ if (process.dev && process.client) {
+ vnode = h(props.routeProps.Component)
+ return vnode
+ }
+
+ return h(props.routeProps.Component)
+ }
}
})
diff --git a/test/basic.test.ts b/test/basic.test.ts
index 16f388dbe50..cee7e7c7e82 100644
--- a/test/basic.test.ts
+++ b/test/basic.test.ts
@@ -1,8 +1,9 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
+import { joinURL } from 'ufo'
// import { isWindows } from 'std-env'
import { setup, fetch, $fetch, startServer } from '@nuxt/test-utils'
-import { expectNoClientErrors } from './utils'
+import { expectNoClientErrors, renderPage } from './utils'
await setup({
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
@@ -359,6 +360,28 @@ describe('automatically keyed composables', () => {
})
})
+if (process.env.NUXT_TEST_DEV) {
+ describe('detecting invalid root nodes', () => {
+ it('should detect invalid root nodes in pages', async () => {
+ for (const path of ['1', '2', '3', '4']) {
+ const { consoleLogs } = await renderPage(joinURL('/invalid-root', path))
+ const consoleLogsWarns = consoleLogs.filter(i => i.type === 'warning').map(w => w.text).join('\n')
+ expect(consoleLogsWarns).toContain('does not have a single root node and will cause errors when navigating between routes')
+ }
+ })
+
+ it('should not complain if there is no transition', async () => {
+ for (const path of ['fine']) {
+ const { consoleLogs } = await renderPage(joinURL('/invalid-root', path))
+
+ const consoleLogsWarns = consoleLogs.filter(i => i.type === 'warning')
+
+ expect(consoleLogsWarns.length).toEqual(0)
+ }
+ })
+ })
+}
+
describe('dynamic paths', () => {
if (process.env.NUXT_TEST_DEV) {
// TODO:
diff --git a/test/fixtures/basic/layouts/invalid-root.vue b/test/fixtures/basic/layouts/invalid-root.vue
new file mode 100644
index 00000000000..51304a719b8
--- /dev/null
+++ b/test/fixtures/basic/layouts/invalid-root.vue
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/test/fixtures/basic/pages/invalid-root/1.vue b/test/fixtures/basic/pages/invalid-root/1.vue
new file mode 100644
index 00000000000..908dc5c7c4f
--- /dev/null
+++ b/test/fixtures/basic/pages/invalid-root/1.vue
@@ -0,0 +1,4 @@
+
+
+ Test
+
diff --git a/test/fixtures/basic/pages/invalid-root/2.vue b/test/fixtures/basic/pages/invalid-root/2.vue
new file mode 100644
index 00000000000..38ce1ce703d
--- /dev/null
+++ b/test/fixtures/basic/pages/invalid-root/2.vue
@@ -0,0 +1,3 @@
+
+ Just some text
+
diff --git a/test/fixtures/basic/pages/invalid-root/3.vue b/test/fixtures/basic/pages/invalid-root/3.vue
new file mode 100644
index 00000000000..60a2297fbe4
--- /dev/null
+++ b/test/fixtures/basic/pages/invalid-root/3.vue
@@ -0,0 +1,4 @@
+
+ Multiple
+ elements
+
diff --git a/test/fixtures/basic/pages/invalid-root/4.vue b/test/fixtures/basic/pages/invalid-root/4.vue
new file mode 100644
index 00000000000..133c2c07f89
--- /dev/null
+++ b/test/fixtures/basic/pages/invalid-root/4.vue
@@ -0,0 +1,7 @@
+
+ Fine
+
+
+
diff --git a/test/fixtures/basic/pages/invalid-root/fine.vue b/test/fixtures/basic/pages/invalid-root/fine.vue
new file mode 100644
index 00000000000..ac450e84e3d
--- /dev/null
+++ b/test/fixtures/basic/pages/invalid-root/fine.vue
@@ -0,0 +1,10 @@
+
+ Multiple
+ elements
+
+
+