Skip to content
This repository was archived by the owner on Apr 6, 2023. It is now read-only.

fix(nuxt): track suspense status so we can share single state #7400

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 35 additions & 25 deletions packages/nuxt/src/app/components/layout.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,64 @@
import { defineComponent, isRef, nextTick, onMounted, Ref, Transition, VNode } from 'vue'
import { computed, defineComponent, isRef, nextTick, h, onMounted, Ref, Transition, VNode } from 'vue'
import { _wrapIf } from './utils'
import { useRoute } from '#app'
// @ts-ignore
import layouts from '#build/layouts'
// @ts-ignore
import { appLayoutTransition as defaultLayoutTransition } from '#build/nuxt.config.mjs'

export default defineComponent({
// TODO: revert back to defineAsyncComponent when https://github.com/vuejs/core/issues/6638 is resolved
const LayoutLoader = defineComponent({
props: {
name: {
type: [String, Boolean, Object] as unknown as () => string | false | Ref<string | false>,
default: null
}
name: String,
...process.dev ? { hasTransition: Boolean } : {}
},
setup (props, context) {
const route = useRoute()

async setup (props, context) {
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.`)
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 () => {
const layout = (isRef(props.name) ? props.name.value : props.name) ?? route.meta.layout as string ?? 'default'
if (process.dev && process.client && props.hasTransition) {
vnode = h(LayoutComponent, {}, context.slots)
return vnode
}
return h(LayoutComponent, {}, context.slots)
}
}
})

const hasLayout = layout && layout in layouts
if (process.dev && layout && !hasLayout && layout !== 'default') {
console.warn(`Invalid layout \`${layout}\` selected.`)
export default defineComponent({
props: {
name: {
type: [String, Boolean, Object] as unknown as () => string | false | Ref<string | false>,
default: null
}
},
setup (props, context) {
const route = useRoute()
const layout = computed(() => (isRef(props.name) ? props.name.value : props.name) ?? route.meta.layout as string ?? 'default')

return () => {
const hasLayout = layout.value && layout.value in layouts
if (process.dev && layout.value && !hasLayout && layout.value !== 'default') {
console.warn(`Invalid layout \`${layout.value}\` selected.`)
}

const transitionProps = route.meta.layoutTransition ?? defaultLayoutTransition

// We avoid rendering layout transition if there is no layout to render
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: () => _wrapIf(LayoutLoader, hasLayout && { key: layout.value, name: layout.value, hasTransition: !!transitionProps }, context.slots).default()
}).default()
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/nuxt/src/app/components/nuxt-root.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<template>
<Suspense @resolve="onResolve">
<ErrorComponent v-if="error" :error="error" />
<App v-else />
<AppComponent v-else />
</Suspense>
</template>

<script setup>
<script setup lang="ts">
import { defineAsyncComponent, onErrorCaptured, provide } from 'vue'
import { callWithNuxt, isNuxtError, showError, useError, useRoute, useNuxtApp } from '#app'

// @ts-expect-error virtual path
import AppComponent from '#build/app-component.mjs'

const ErrorComponent = defineAsyncComponent(() => import('#build/error-component.mjs').then(r => r.default || r))

const nuxtApp = useNuxtApp()
Expand Down
4 changes: 0 additions & 4 deletions packages/nuxt/src/app/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import '#build/css'
import _plugins from '#build/plugins'
// @ts-ignore
import RootComponent from '#build/root-component.mjs'
// @ts-ignore
import AppComponent from '#build/app-component.mjs'

if (!globalThis.$fetch) {
// @ts-ignore
Expand All @@ -26,7 +24,6 @@ const plugins = normalizePlugins(_plugins)
if (process.server) {
entry = async function createNuxtAppServer (ssrContext: CreateOptions['ssrContext']) {
const vueApp = createApp(RootComponent)
vueApp.component('App', AppComponent)

const nuxt = createNuxtApp({ vueApp, ssrContext })

Expand Down Expand Up @@ -54,7 +51,6 @@ if (process.client) {
entry = async function initApp () {
const isSSR = Boolean(window.__NUXT__?.serverRendered)
const vueApp = isSSR ? createSSRApp(RootComponent) : createApp(RootComponent)
vueApp.component('App', AppComponent)

const nuxt = createNuxtApp({ vueApp })

Expand Down
3 changes: 1 addition & 2 deletions packages/nuxt/src/core/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,9 @@ export const layoutTemplate: NuxtTemplate<TemplateContext> = {
filename: 'layouts.mjs',
getContents ({ app }) {
const layoutsObject = genObjectFromRawEntries(Object.values(app.layouts).map(({ name, file }) => {
return [name, `defineAsyncComponent(${genDynamicImport(file, { interopDefault: true })})`]
return [name, genDynamicImport(file, { interopDefault: true })]
}))
return [
'import { defineAsyncComponent } from \'vue\'',
`export default ${layoutsObject}`
].join('\n')
}
Expand Down
12 changes: 4 additions & 8 deletions packages/nuxt/src/pages/runtime/page.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, defineComponent, h, inject, provide, reactive, onMounted, nextTick, Suspense, Transition, KeepAliveProps, TransitionProps } from 'vue'
import { computed, defineComponent, h, provide, reactive, onMounted, nextTick, Suspense, Transition, KeepAliveProps, TransitionProps } from 'vue'
import type { DefineComponent, VNode } from 'vue'
import { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
import type { RouteLocation } from 'vue-router'
Expand All @@ -9,8 +9,6 @@ import { _wrapIf } from '#app/components/utils'
// @ts-ignore
import { appPageTransition as defaultPageTransition, appKeepalive as defaultKeepaliveConfig } from '#build/nuxt.config.mjs'

const isNestedKey = Symbol('isNested')

export default defineComponent({
name: 'NuxtPage',
inheritAttrs: false,
Expand All @@ -37,9 +35,6 @@ export default defineComponent({
setup (props, { attrs }) {
const nuxtApp = useNuxtApp()

const isNested = inject(isNestedKey, false)
provide(isNestedKey, true)

return () => {
return h(RouterView, { name: props.name, route: props.route, ...attrs }, {
default: (routeProps: RouterViewSlotProps) => {
Expand All @@ -49,8 +44,9 @@ export default defineComponent({
const transitionProps = props.transition ?? routeProps.route.meta.pageTransition ?? (defaultPageTransition as TransitionProps)

return _wrapIf(Transition, transitionProps,
wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), isNested && nuxtApp.isHydrating
// Include route children in parent suspense
// If we're already wrapped in a page component that is in-transition
// we can piggy-back on its suspense so we resolve at the same time.
wrapInKeepAlive(props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps), nuxtApp.isHydrating || nuxtApp.isResolvingSuspense
? h(Component, { key, routeProps, pageKey: key, hasTransition: !!transitionProps } as {})
: h(Suspense, {
onPending: () => nuxtApp.callHook('page:start', routeProps.Component),
Expand Down
12 changes: 12 additions & 0 deletions packages/nuxt/src/pages/runtime/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ export default defineNuxtPlugin(async (nuxtApp) => {
})
nuxtApp.vueApp.use(router)

// Ensure we don't trigger multiple suspenses in page navigation
nuxtApp.isResolvingSuspense = true
nuxtApp.hook('page:start', () => {
nuxtApp.isResolvingSuspense = true
})
nuxtApp.hook('page:finish', () => {
nuxtApp.isResolvingSuspense = false
})
nuxtApp.hook('app:suspense:resolve', () => {
nuxtApp.isResolvingSuspense = false
})

const previousRoute = shallowRef(router.currentRoute.value)
router.afterEach((_to, from) => {
previousRoute.value = from
Expand Down
17 changes: 17 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,23 @@ describe('automatically keyed composables', () => {
})
})

describe('single suspense tree', () => {
it('should generate a single suspense tree on initial hydration', async () => {
const page = await createPage()
const logs: string[] = []
page.on('console', (msg) => {
const text = msg.text()
if (text.includes('isHydrating')) {
logs.push(text)
}
})
await page.goto(url('/another-parent'))
await page.waitForLoadState('networkidle')
expect(logs.length).toBe(3)
expect(logs.every(log => log === 'isHydrating: true'))
})
})

describe.skipIf(process.env.NUXT_TEST_DEV || process.env.TEST_WITH_WEBPACK)('inlining component styles', () => {
it('should inline styles', async () => {
const html = await $fetch('/styles')
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/basic/layouts/custom.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@
<slot />
</div>
</template>

<script setup>
await Promise.resolve()
console.log('isHydrating: ' + useNuxtApp().isHydrating)
</script>
8 changes: 8 additions & 0 deletions test/fixtures/basic/pages/another-parent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,11 @@
<NuxtPage />
</div>
</template>

<script setup>
await Promise.resolve()
console.log('isHydrating: ' + useNuxtApp().isHydrating)
definePageMeta({
layout: 'custom'
})
</script>
5 changes: 5 additions & 0 deletions test/fixtures/basic/pages/another-parent/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
another-parent/index
</div>
</template>

<script setup>
await Promise.resolve()
console.log('isHydrating: ' + useNuxtApp().isHydrating)
</script>