diff --git a/nuxt.config.ts b/nuxt.config.ts index 9763e21d43..2dde065460 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,5 +1,6 @@ // For pnpm typecheck:docs to generate correct types +import { fileURLToPath } from 'node:url' import { addPluginTemplate, addRouteMiddleware } from 'nuxt/kit' export default defineNuxtConfig({ @@ -17,6 +18,9 @@ export default defineNuxtConfig({ }, ], pages: process.env.DOCS_TYPECHECK === 'true', + dir: { + app: fileURLToPath(new URL('./test/runtime/app', import.meta.url)), + }, typescript: { shim: process.env.DOCS_TYPECHECK === 'true', hoist: ['@vitejs/plugin-vue', 'vue-router'], diff --git a/packages/nuxt/src/app/components/route-provider.ts b/packages/nuxt/src/app/components/route-provider.ts index 16ac724bc7..3da2c87251 100644 --- a/packages/nuxt/src/app/components/route-provider.ts +++ b/packages/nuxt/src/app/components/route-provider.ts @@ -3,7 +3,8 @@ import type { Ref, VNode } from 'vue' import type { RouteLocationNormalizedLoaded } from 'vue-router' import { PageRouteSymbol } from './injections' -export const RouteProvider = defineComponent({ +export const defineRouteProvider = (name = 'RouteProvider') => defineComponent({ + name, props: { vnode: { type: Object as () => VNode, @@ -55,3 +56,5 @@ export const RouteProvider = defineComponent({ } }, }) + +export const RouteProvider = defineRouteProvider() diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts index 3d52ab622b..c4eba14872 100644 --- a/packages/nuxt/src/pages/runtime/page.ts +++ b/packages/nuxt/src/pages/runtime/page.ts @@ -1,12 +1,12 @@ import { Fragment, Suspense, defineComponent, h, inject, nextTick, ref, watch } from 'vue' -import type { AllowedComponentProps, ComponentCustomProps, ComponentPublicInstance, KeepAliveProps, TransitionProps, VNode, VNodeProps } from 'vue' +import type { AllowedComponentProps, Component, ComponentCustomProps, ComponentPublicInstance, KeepAliveProps, Slot, TransitionProps, VNode, VNodeProps } from 'vue' import { RouterView } from 'vue-router' import { defu } from 'defu' import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouterViewProps } from 'vue-router' import { generateRouteKey, toArray, wrapInKeepAlive } from './utils' import type { RouterViewSlotProps } from './utils' -import { RouteProvider } from '#app/components/route-provider' +import { RouteProvider, defineRouteProvider } from '#app/components/route-provider' import { useNuxtApp } from '#app/nuxt' import { useRouter } from '#app/composables/router' import { _wrapInTransition } from '#app/components/utils' @@ -83,6 +83,9 @@ export default defineComponent({ nuxtApp._isNuxtPageUsed = true } let pageLoadingEndHookAlreadyCalled = false + + const routerProviderLookup = new WeakMap | undefined>() + return () => { return h(RouterView, { name: props.name, route: props.route, ...attrs }, { default: (routeProps: RouterViewSlotProps) => { @@ -128,7 +131,7 @@ export default defineComponent({ default: () => { const providerVNode = h(RouteProvider, { key: key || undefined, - vnode: slots.default ? h(Fragment, undefined, slots.default(routeProps)) : routeProps.Component, + vnode: slots.default ? normalizeSlot(slots.default, routeProps) : routeProps.Component, route: routeProps.route, renderKey: key || undefined, vnodeRef: pageRef, @@ -141,7 +144,6 @@ export default defineComponent({ } // Client side rendering - const hasTransition = !!(props.transition ?? routeProps.route.meta.pageTransition ?? defaultPageTransition) const transitionProps = hasTransition && _mergeTransitionProps([ props.transition, @@ -165,18 +167,28 @@ export default defineComponent({ }, }, { default: () => { - const providerVNode = h(RouteProvider, { + const routeProviderProps = { key: key || undefined, - vnode: slots.default ? h(Fragment, undefined, slots.default(routeProps)) : routeProps.Component, + vnode: slots.default ? normalizeSlot(slots.default, routeProps) : routeProps.Component, route: routeProps.route, renderKey: key || undefined, trackRootNodes: hasTransition, vnodeRef: pageRef, - }) - if (keepaliveConfig) { - (providerVNode.type as any).name = (routeProps.Component.type as any).name || (routeProps.Component.type as any).__name || 'RouteProvider' } - return providerVNode + + if (!keepaliveConfig) { + return h(RouteProvider, routeProviderProps) + } + + const routerComponentType = routeProps.Component.type as any + let PageRouteProvider = routerProviderLookup.get(routerComponentType) + + if (!PageRouteProvider) { + PageRouteProvider = defineRouteProvider(routerComponentType.name || routerComponentType.__name) + routerProviderLookup.set(routerComponentType, PageRouteProvider) + } + + return h(PageRouteProvider, routeProviderProps) }, }), )).default() @@ -232,3 +244,8 @@ function hasChildrenRoutes (fork: RouteLocationNormalizedLoaded | null, newRoute const index = newRoute.matched.findIndex(m => m.components?.default === Component?.type) return index < newRoute.matched.length - 1 } + +function normalizeSlot (slot: Slot, data: RouterViewSlotProps) { + const slotContent = slot(data) + return slotContent.length === 1 ? h(slotContent[0]!) : h(Fragment, undefined, slotContent) +} diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index 86568528be..e13c7929c7 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -661,14 +661,13 @@ describe('routing utilities: `encodeURL`', () => { }) describe('routing utilities: `useRoute`', () => { - it('should show provide a mock route', () => { + it('should provide a route', () => { expect(useRoute()).toMatchObject({ fullPath: '/', hash: '', - href: '/', - matched: [], + matched: expect.arrayContaining([]), meta: {}, - name: undefined, + name: 'catchall', params: {}, path: '/', query: {}, diff --git a/test/nuxt/nuxt-page.test.ts b/test/nuxt/nuxt-page.test.ts new file mode 100644 index 0000000000..59e00c641b --- /dev/null +++ b/test/nuxt/nuxt-page.test.ts @@ -0,0 +1,97 @@ +/// + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { NuxtLayout, NuxtPage } from '#components' + +describe('NuxtPage should work with keepalive options', () => { + let visits = 0 + const router = useRouter() + beforeEach(() => { + visits = 0 + router.addRoute({ + name: 'home', + path: '/home', + component: defineComponent({ + name: 'home', + setup () { + visits++ + return () => h('div', 'home') + }, + }), + }) + }) + afterEach(() => { + router.removeRoute('home') + }) + // include/exclude/boolean + it('should reload setup every time a page is visited, without keepalive', async () => { + const el = await mountSuspended({ + setup () { + return () => h(NuxtLayout, {}, { default: () => h(NuxtPage) }) + }, + }) + await navigateTo('/home') + await navigateTo('/') + await navigateTo('/home') + expect(visits).toBe(2) + el.unmount() + }) + + it('should not remount a page when keepalive is enabled', async () => { + const el = await mountSuspended({ + setup () { + return () => h(NuxtLayout, {}, { default: () => h(NuxtPage, { keepalive: true }) }) + }, + }) + await navigateTo('/home') + await navigateTo('/') + await navigateTo('/home') + expect(visits).toBe(1) + el.unmount() + }) + + it('should not remount a page when keepalive is granularly enabled (with include)', async () => { + const el = await mountSuspended({ + setup () { + return () => h(NuxtLayout, {}, { default: () => h(NuxtPage, { keepalive: { include: ['home'] } }) }) + }, + }) + await navigateTo('/home') + await navigateTo('/') + await navigateTo('/home') + expect(visits).toBe(1) + el.unmount() + }) + + it('should not remount a page when keepalive is granularly enabled (with exclude)', async () => { + const el = await mountSuspended({ + setup () { + return () => h(NuxtLayout, {}, { default: () => h(NuxtPage, { keepalive: { exclude: ['catchall'] } }) }) + }, + }) + await navigateTo('/home') + await navigateTo('/') + await navigateTo('/home') + expect(visits).toBe(1) + el.unmount() + }) + + it('should not remount a page when keepalive options are modified', async () => { + const pages = ref('home') + const el = await mountSuspended({ + setup () { + return () => h(NuxtLayout, {}, { default: () => h(NuxtPage, { keepalive: { include: pages.value } }) }) + }, + }) + await navigateTo('/home') + await navigateTo('/') + await navigateTo('/home') + pages.value = 'home,catchall' + await navigateTo('/') + await navigateTo('/home') + expect(visits).toBe(1) + el.unmount() + }) +}) diff --git a/test/runtime/app/router.options.ts b/test/runtime/app/router.options.ts new file mode 100644 index 0000000000..7655b9594c --- /dev/null +++ b/test/runtime/app/router.options.ts @@ -0,0 +1,17 @@ +import type { RouterOptions } from 'nuxt/schema' +import { defineComponent } from 'vue' + +export default { + routes (_routes) { + return [ + { + name: 'catchall', + path: '/:catchAll(.*)*', + component: defineComponent({ + name: 'catchall', + setup: () => () => ({}), + }), + }, + ] + }, +} diff --git a/vitest.nuxt.config.ts b/vitest.nuxt.config.ts index 6c0ef9cabc..31f2c9e6a2 100644 --- a/vitest.nuxt.config.ts +++ b/vitest.nuxt.config.ts @@ -13,6 +13,7 @@ export default defineVitestConfig({ environmentOptions: { nuxt: { overrides: { + pages: true, runtimeConfig: { app: { buildId: 'override',