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 @@ + 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 @@ + 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 @@ + 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 @@ + + + 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 @@ + + +