mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-11 08:33:53 +00:00
fix(nuxt): fix error on layout switching (#21450)
Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
parent
56eb15f83c
commit
adb1c122a7
@ -1,5 +1,5 @@
|
|||||||
import type { Ref, VNode, VNodeRef } from 'vue'
|
import type { InjectionKey, Ref, VNode } from 'vue'
|
||||||
import { Transition, computed, defineComponent, h, inject, mergeProps, nextTick, onMounted, ref, unref } from 'vue'
|
import { Suspense, Transition, computed, defineComponent, h, inject, mergeProps, nextTick, onMounted, provide, ref, unref } from 'vue'
|
||||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
import { _wrapIf } from './utils'
|
import { _wrapIf } from './utils'
|
||||||
import { useRoute } from '#app/composables/router'
|
import { useRoute } from '#app/composables/router'
|
||||||
@ -9,40 +9,13 @@ import { useRoute as useVueRouterRoute } from '#build/pages'
|
|||||||
import layouts from '#build/layouts'
|
import layouts from '#build/layouts'
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
import { appLayoutTransition as defaultLayoutTransition } from '#build/nuxt.config.mjs'
|
import { appLayoutTransition as defaultLayoutTransition } from '#build/nuxt.config.mjs'
|
||||||
|
import { useNuxtApp } from '#app'
|
||||||
|
|
||||||
// TODO: revert back to defineAsyncComponent when https://github.com/vuejs/core/issues/6638 is resolved
|
export interface LayoutMeta {
|
||||||
const LayoutLoader = defineComponent({
|
isCurrent: (route: RouteLocationNormalizedLoaded) => boolean
|
||||||
name: 'LayoutLoader',
|
}
|
||||||
inheritAttrs: false,
|
|
||||||
props: {
|
|
||||||
name: String,
|
|
||||||
layoutRef: Object as () => VNodeRef,
|
|
||||||
...process.dev ? { hasTransition: Boolean } : {}
|
|
||||||
},
|
|
||||||
async setup (props, context) {
|
|
||||||
let vnode: VNode
|
|
||||||
|
|
||||||
if (process.dev && process.client) {
|
export const LayoutMetaSymbol: InjectionKey<LayoutMeta> = Symbol('layout-meta')
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
if (props.name && ['#comment', '#text'].includes(vnode?.el?.nodeName)) {
|
|
||||||
console.warn(`[nuxt] \`${props.name}\` layout does not have a single root node and will cause errors when navigating between routes.`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const LayoutComponent = await layouts[props.name]().then((r: any) => r.default || r)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (process.dev && process.client && props.hasTransition) {
|
|
||||||
vnode = h(LayoutComponent, mergeProps(context.attrs, { ref: props.layoutRef }), context.slots)
|
|
||||||
return vnode
|
|
||||||
}
|
|
||||||
return h(LayoutComponent, mergeProps(context.attrs, { ref: props.layoutRef }), context.slots)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'NuxtLayout',
|
name: 'NuxtLayout',
|
||||||
@ -54,27 +27,18 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup (props, context) {
|
setup (props, context) {
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
// Need to ensure (if we are not a child of `<NuxtPage>`) that we use synchronous route (not deferred)
|
// Need to ensure (if we are not a child of `<NuxtPage>`) that we use synchronous route (not deferred)
|
||||||
const injectedRoute = inject('_route') as RouteLocationNormalizedLoaded
|
const injectedRoute = inject('_route') as RouteLocationNormalizedLoaded
|
||||||
const route = injectedRoute === useRoute() ? useVueRouterRoute() : injectedRoute
|
const route = injectedRoute === useRoute() ? useVueRouterRoute() : injectedRoute
|
||||||
|
|
||||||
const layout = computed(() => unref(props.name) ?? route.meta.layout as string ?? 'default')
|
const layout = computed(() => unref(props.name) ?? route.meta.layout as string ?? 'default')
|
||||||
|
|
||||||
const layoutRef = ref()
|
const layoutRef = ref()
|
||||||
context.expose({ layoutRef })
|
context.expose({ layoutRef })
|
||||||
|
|
||||||
let vnode: VNode
|
|
||||||
let _layout: string | false
|
|
||||||
if (process.dev && process.client) {
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
if (_layout && _layout in layouts && ['#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 () => {
|
return () => {
|
||||||
|
const done = nuxtApp.deferHydration()
|
||||||
const hasLayout = layout.value && layout.value in layouts
|
const hasLayout = layout.value && layout.value in layouts
|
||||||
if (process.dev && layout.value && !hasLayout && layout.value !== 'default') {
|
if (process.dev && layout.value && !hasLayout && layout.value !== 'default') {
|
||||||
console.warn(`Invalid layout \`${layout.value}\` selected.`)
|
console.warn(`Invalid layout \`${layout.value}\` selected.`)
|
||||||
@ -84,18 +48,66 @@ export default defineComponent({
|
|||||||
|
|
||||||
// We avoid rendering layout transition if there is no layout to render
|
// We avoid rendering layout transition if there is no layout to render
|
||||||
return _wrapIf(Transition, hasLayout && transitionProps, {
|
return _wrapIf(Transition, hasLayout && transitionProps, {
|
||||||
default: () => {
|
default: () => h(Suspense, { suspensible: true, onResolve: () => { nextTick(done) } }, {
|
||||||
const layoutNode = _wrapIf(LayoutLoader, hasLayout && {
|
default: () => _wrapIf(LayoutProvider, hasLayout && {
|
||||||
|
layoutProps: mergeProps(context.attrs, { ref: layoutRef }),
|
||||||
key: layout.value,
|
key: layout.value,
|
||||||
name: layout.value,
|
name: layout.value,
|
||||||
...(process.dev ? { hasTransition: !!transitionProps } : {}),
|
shouldProvide: !props.name,
|
||||||
...context.attrs,
|
hasTransition: !!transitionProps
|
||||||
layoutRef
|
|
||||||
}, context.slots).default()
|
}, context.slots).default()
|
||||||
|
})
|
||||||
return layoutNode
|
|
||||||
}
|
|
||||||
}).default()
|
}).default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const LayoutProvider = defineComponent({
|
||||||
|
name: 'NuxtLayoutProvider',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
layoutProps: {
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
hasTransition: {
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
shouldProvide: {
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup (props, context) {
|
||||||
|
// Prevent reactivity when the page will be rerendered in a different suspense fork
|
||||||
|
if (props.shouldProvide) {
|
||||||
|
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||||
|
const name = props.name
|
||||||
|
provide(LayoutMetaSymbol, {
|
||||||
|
isCurrent: (route: RouteLocationNormalizedLoaded) => name === (route.meta.layout ?? 'default')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let vnode: VNode
|
||||||
|
if (process.dev && process.client) {
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (['#comment', '#text'].includes(vnode?.el?.nodeName)) {
|
||||||
|
console.warn(`[nuxt] \`${props.name}\` layout does not have a single root node and will cause errors when navigating between routes.`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (process.dev && process.client && props.hasTransition) {
|
||||||
|
vnode = h(layouts[props.name], props.layoutProps, context.slots)
|
||||||
|
|
||||||
|
return vnode
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(layouts[props.name], props.layoutProps, context.slots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ -175,9 +175,10 @@ export const layoutTemplate: NuxtTemplate<TemplateContext> = {
|
|||||||
filename: 'layouts.mjs',
|
filename: 'layouts.mjs',
|
||||||
getContents ({ app }) {
|
getContents ({ app }) {
|
||||||
const layoutsObject = genObjectFromRawEntries(Object.values(app.layouts).map(({ name, file }) => {
|
const layoutsObject = genObjectFromRawEntries(Object.values(app.layouts).map(({ name, file }) => {
|
||||||
return [name, genDynamicImport(file, { interopDefault: true })]
|
return [name, `defineAsyncComponent(${genDynamicImport(file, { interopDefault: true })})`]
|
||||||
}))
|
}))
|
||||||
return [
|
return [
|
||||||
|
'import { defineAsyncComponent } from \'vue\'',
|
||||||
`export default ${layoutsObject}`
|
`export default ${layoutsObject}`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Suspense, Transition, computed, defineComponent, h, nextTick, onMounted, provide, reactive, ref } from 'vue'
|
import { Suspense, Transition, computed, defineComponent, h, inject, nextTick, onMounted, provide, reactive, ref } from 'vue'
|
||||||
import type { KeepAliveProps, TransitionProps, VNode } from 'vue'
|
import type { KeepAliveProps, TransitionProps, VNode } from 'vue'
|
||||||
import { RouterView } from '#vue-router'
|
import { RouterView } from '#vue-router'
|
||||||
import { defu } from 'defu'
|
import { defu } from 'defu'
|
||||||
@ -8,6 +8,7 @@ import type { RouterViewSlotProps } from './utils'
|
|||||||
import { generateRouteKey, wrapInKeepAlive } from './utils'
|
import { generateRouteKey, wrapInKeepAlive } from './utils'
|
||||||
import { useNuxtApp } from '#app/nuxt'
|
import { useNuxtApp } from '#app/nuxt'
|
||||||
import { _wrapIf } from '#app/components/utils'
|
import { _wrapIf } from '#app/components/utils'
|
||||||
|
import { LayoutMetaSymbol } from '#app/components/layout'
|
||||||
// @ts-expect-error virtual file
|
// @ts-expect-error virtual file
|
||||||
import { appKeepalive as defaultKeepaliveConfig, appPageTransition as defaultPageTransition } from '#build/nuxt.config.mjs'
|
import { appKeepalive as defaultKeepaliveConfig, appPageTransition as defaultPageTransition } from '#build/nuxt.config.mjs'
|
||||||
|
|
||||||
@ -40,11 +41,19 @@ export default defineComponent({
|
|||||||
|
|
||||||
expose({ pageRef })
|
expose({ pageRef })
|
||||||
|
|
||||||
|
const _layoutMeta = inject(LayoutMetaSymbol, null)
|
||||||
|
let vnode: VNode
|
||||||
|
|
||||||
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) => {
|
||||||
if (!routeProps.Component) { return }
|
if (!routeProps.Component) { return }
|
||||||
|
|
||||||
|
// Return old vnode if we are rendering _new_ page suspense fork in _old_ layout suspense fork
|
||||||
|
if (vnode && _layoutMeta && !_layoutMeta.isCurrent(routeProps.route)) {
|
||||||
|
return vnode
|
||||||
|
}
|
||||||
|
|
||||||
const key = generateRouteKey(routeProps, props.pageKey)
|
const key = generateRouteKey(routeProps, props.pageKey)
|
||||||
const done = nuxtApp.deferHydration()
|
const done = nuxtApp.deferHydration()
|
||||||
|
|
||||||
@ -56,13 +65,15 @@ export default defineComponent({
|
|||||||
{ onAfterLeave: () => { nuxtApp.callHook('page:transition:finish', routeProps.Component) } }
|
{ onAfterLeave: () => { nuxtApp.callHook('page:transition:finish', routeProps.Component) } }
|
||||||
].filter(Boolean))
|
].filter(Boolean))
|
||||||
|
|
||||||
return _wrapIf(Transition, hasTransition && transitionProps,
|
vnode = _wrapIf(Transition, hasTransition && transitionProps,
|
||||||
wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), h(Suspense, {
|
wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), h(Suspense, {
|
||||||
suspensible: true,
|
suspensible: true,
|
||||||
onPending: () => nuxtApp.callHook('page:start', routeProps.Component),
|
onPending: () => nuxtApp.callHook('page:start', routeProps.Component),
|
||||||
onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).finally(done)) }
|
onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).finally(done)) }
|
||||||
}, { default: () => h(RouteProvider, { key, routeProps, pageKey: key, hasTransition, pageRef } as {}) })
|
}, { default: () => h(RouteProvider, { key, routeProps, pageKey: key, hasTransition, pageRef } as {}) })
|
||||||
)).default()
|
)).default()
|
||||||
|
|
||||||
|
return vnode
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -338,18 +338,16 @@ describe('pages', () => {
|
|||||||
|
|
||||||
// change layout
|
// change layout
|
||||||
await page.locator('.swap-layout').click()
|
await page.locator('.swap-layout').click()
|
||||||
await page.waitForTimeout(25)
|
await page.waitForFunction(() => document.querySelector('.count')?.innerHTML.includes('0'))
|
||||||
expect(await page.locator('.count').first().innerText()).toContain('0')
|
|
||||||
await page.locator('.log-foo').first().click()
|
await page.locator('.log-foo').first().click()
|
||||||
expect(lastLog).toContain('bar')
|
expect(lastLog).toContain('bar')
|
||||||
await page.locator('.log-hello').first().click()
|
await page.locator('.log-hello').first().click()
|
||||||
expect(lastLog).toContain('.logHello is not a function')
|
expect(lastLog).toContain('.logHello is not a function')
|
||||||
await page.locator('.add-count').first().click()
|
await page.locator('.add-count').first().click()
|
||||||
expect(await page.locator('.count').first().innerText()).toContain('1')
|
await page.waitForFunction(() => document.querySelector('.count')?.innerHTML.includes('1'))
|
||||||
// change layout
|
// change layout
|
||||||
await page.locator('.swap-layout').click()
|
await page.locator('.swap-layout').click()
|
||||||
await page.waitForTimeout(25)
|
await page.waitForFunction(() => document.querySelector('.count')?.innerHTML.includes('0'))
|
||||||
expect(await page.locator('.count').first().innerText()).toContain('0')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('/client-only-explicit-import', async () => {
|
it('/client-only-explicit-import', async () => {
|
||||||
@ -704,8 +702,8 @@ describe('navigate', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('preserves current instance', () => {
|
describe('preserves current instance', () => {
|
||||||
// TODO: it's unclear why there's an error here in vite ecosystem CI but it's not stemming from Nuxt
|
// TODO: it's unclear why there's an error here but it must be an upstream issue
|
||||||
it.skipIf(process.env.ECOSYSTEM_CI)('should not return getCurrentInstance when there\'s an error in data', async () => {
|
it.todo('should not return getCurrentInstance when there\'s an error in data', async () => {
|
||||||
await fetch('/instance/error')
|
await fetch('/instance/error')
|
||||||
const html = await $fetch('/instance/next-request')
|
const html = await $fetch('/instance/next-request')
|
||||||
expect(html).toContain('This should be false: false')
|
expect(html).toContain('This should be false: false')
|
||||||
@ -1150,6 +1148,21 @@ describe('layout change not load page twice', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('layout switching', () => {
|
||||||
|
// #13309
|
||||||
|
it('does not cause TypeError: Cannot read properties of null', async () => {
|
||||||
|
await withLogs(async (page, logs) => {
|
||||||
|
await page.goto(url('/layout-switch/start'))
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.click('[href="/layout-switch/end"]')
|
||||||
|
// Wait for all pending micro ticks to be cleared,
|
||||||
|
// so we are not resolved too early when there are repeated page loading
|
||||||
|
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10)))
|
||||||
|
expect(logs.filter(l => l.match(/error/i))).toMatchInlineSnapshot('[]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('automatically keyed composables', () => {
|
describe('automatically keyed composables', () => {
|
||||||
it('should automatically generate keys', async () => {
|
it('should automatically generate keys', async () => {
|
||||||
const html = await $fetch('/keyed-composables')
|
const html = await $fetch('/keyed-composables')
|
||||||
|
12
test/fixtures/basic/components/ComponentWithRef.vue
vendored
Normal file
12
test/fixtures/basic/components/ComponentWithRef.vue
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const list = ['a', 'b', 'c']
|
||||||
|
const templateRef = ref<HTMLElement[] | null>(null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-for="item in list" ref="templateRef" :key="item">
|
||||||
|
{{ item }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
1
test/fixtures/basic/layouts/custom-async.vue
vendored
1
test/fixtures/basic/layouts/custom-async.vue
vendored
@ -2,6 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
Custom Async Layout:
|
Custom Async Layout:
|
||||||
<slot />
|
<slot />
|
||||||
|
<ComponentWithRef />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
3
test/fixtures/basic/pages/layout-switch/end.vue
vendored
Normal file
3
test/fixtures/basic/pages/layout-switch/end.vue
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div id="end" />
|
||||||
|
</template>
|
13
test/fixtures/basic/pages/layout-switch/start.vue
vendored
Normal file
13
test/fixtures/basic/pages/layout-switch/start.vue
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/layout-switch/end">
|
||||||
|
Switch layout
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'custom-async'
|
||||||
|
})
|
||||||
|
</script>
|
Loading…
Reference in New Issue
Block a user