fix(nuxt): prevent keepalive cache reset (#30807)

This commit is contained in:
Alex Liu 2025-02-07 02:10:35 +08:00 committed by GitHub
parent 4be52d341f
commit 5e7d4938cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 153 additions and 15 deletions

View File

@ -1,5 +1,6 @@
// For pnpm typecheck:docs to generate correct types // For pnpm typecheck:docs to generate correct types
import { fileURLToPath } from 'node:url'
import { addPluginTemplate, addRouteMiddleware } from 'nuxt/kit' import { addPluginTemplate, addRouteMiddleware } from 'nuxt/kit'
export default defineNuxtConfig({ export default defineNuxtConfig({
@ -17,6 +18,9 @@ export default defineNuxtConfig({
}, },
], ],
pages: process.env.DOCS_TYPECHECK === 'true', pages: process.env.DOCS_TYPECHECK === 'true',
dir: {
app: fileURLToPath(new URL('./test/runtime/app', import.meta.url)),
},
typescript: { typescript: {
shim: process.env.DOCS_TYPECHECK === 'true', shim: process.env.DOCS_TYPECHECK === 'true',
hoist: ['@vitejs/plugin-vue', 'vue-router'], hoist: ['@vitejs/plugin-vue', 'vue-router'],

View File

@ -3,7 +3,8 @@ import type { Ref, VNode } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { PageRouteSymbol } from './injections' import { PageRouteSymbol } from './injections'
export const RouteProvider = defineComponent({ export const defineRouteProvider = (name = 'RouteProvider') => defineComponent({
name,
props: { props: {
vnode: { vnode: {
type: Object as () => VNode, type: Object as () => VNode,
@ -55,3 +56,5 @@ export const RouteProvider = defineComponent({
} }
}, },
}) })
export const RouteProvider = defineRouteProvider()

View File

@ -1,12 +1,12 @@
import { Fragment, Suspense, defineComponent, h, inject, nextTick, ref, watch } from 'vue' 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 { RouterView } from 'vue-router'
import { defu } from 'defu' import { defu } from 'defu'
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouterViewProps } from 'vue-router' import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouterViewProps } from 'vue-router'
import { generateRouteKey, toArray, wrapInKeepAlive } from './utils' import { generateRouteKey, toArray, wrapInKeepAlive } from './utils'
import type { RouterViewSlotProps } 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 { useNuxtApp } from '#app/nuxt'
import { useRouter } from '#app/composables/router' import { useRouter } from '#app/composables/router'
import { _wrapInTransition } from '#app/components/utils' import { _wrapInTransition } from '#app/components/utils'
@ -83,6 +83,9 @@ export default defineComponent({
nuxtApp._isNuxtPageUsed = true nuxtApp._isNuxtPageUsed = true
} }
let pageLoadingEndHookAlreadyCalled = false let pageLoadingEndHookAlreadyCalled = false
const routerProviderLookup = new WeakMap<Component, ReturnType<typeof defineRouteProvider> | undefined>()
return () => { return () => {
return h(RouterView, { name: props.name, route: props.route, ...attrs }, { return h(RouterView, { name: props.name, route: props.route, ...attrs }, {
default: (routeProps: RouterViewSlotProps) => { default: (routeProps: RouterViewSlotProps) => {
@ -128,7 +131,7 @@ export default defineComponent({
default: () => { default: () => {
const providerVNode = h(RouteProvider, { const providerVNode = h(RouteProvider, {
key: key || undefined, 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, route: routeProps.route,
renderKey: key || undefined, renderKey: key || undefined,
vnodeRef: pageRef, vnodeRef: pageRef,
@ -141,7 +144,6 @@ export default defineComponent({
} }
// Client side rendering // Client side rendering
const hasTransition = !!(props.transition ?? routeProps.route.meta.pageTransition ?? defaultPageTransition) const hasTransition = !!(props.transition ?? routeProps.route.meta.pageTransition ?? defaultPageTransition)
const transitionProps = hasTransition && _mergeTransitionProps([ const transitionProps = hasTransition && _mergeTransitionProps([
props.transition, props.transition,
@ -165,18 +167,28 @@ export default defineComponent({
}, },
}, { }, {
default: () => { default: () => {
const providerVNode = h(RouteProvider, { const routeProviderProps = {
key: key || undefined, 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, route: routeProps.route,
renderKey: key || undefined, renderKey: key || undefined,
trackRootNodes: hasTransition, trackRootNodes: hasTransition,
vnodeRef: pageRef, 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() )).default()
@ -232,3 +244,8 @@ function hasChildrenRoutes (fork: RouteLocationNormalizedLoaded | null, newRoute
const index = newRoute.matched.findIndex(m => m.components?.default === Component?.type) const index = newRoute.matched.findIndex(m => m.components?.default === Component?.type)
return index < newRoute.matched.length - 1 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)
}

View File

@ -661,14 +661,13 @@ describe('routing utilities: `encodeURL`', () => {
}) })
describe('routing utilities: `useRoute`', () => { describe('routing utilities: `useRoute`', () => {
it('should show provide a mock route', () => { it('should provide a route', () => {
expect(useRoute()).toMatchObject({ expect(useRoute()).toMatchObject({
fullPath: '/', fullPath: '/',
hash: '', hash: '',
href: '/', matched: expect.arrayContaining([]),
matched: [],
meta: {}, meta: {},
name: undefined, name: 'catchall',
params: {}, params: {},
path: '/', path: '/',
query: {}, query: {},

View File

@ -0,0 +1,97 @@
/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
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()
})
})

View File

@ -0,0 +1,17 @@
import type { RouterOptions } from 'nuxt/schema'
import { defineComponent } from 'vue'
export default <RouterOptions> {
routes (_routes) {
return [
{
name: 'catchall',
path: '/:catchAll(.*)*',
component: defineComponent({
name: 'catchall',
setup: () => () => ({}),
}),
},
]
},
}

View File

@ -13,6 +13,7 @@ export default defineVitestConfig({
environmentOptions: { environmentOptions: {
nuxt: { nuxt: {
overrides: { overrides: {
pages: true,
runtimeConfig: { runtimeConfig: {
app: { app: {
buildId: 'override', buildId: 'override',