diff --git a/packages/nuxt/src/app/components/injections.ts b/packages/nuxt/src/app/components/injections.ts new file mode 100644 index 0000000000..3f9660e607 --- /dev/null +++ b/packages/nuxt/src/app/components/injections.ts @@ -0,0 +1,10 @@ +import type { InjectionKey } from 'vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +export interface LayoutMeta { + isCurrent: (route: RouteLocationNormalizedLoaded) => boolean +} + +export const LayoutMetaSymbol: InjectionKey = Symbol('layout-meta') + +export const PageRouteSymbol: InjectionKey = Symbol('route') diff --git a/packages/nuxt/src/app/components/layout.ts b/packages/nuxt/src/app/components/layout.ts index e2253c42b5..8a0d633996 100644 --- a/packages/nuxt/src/app/components/layout.ts +++ b/packages/nuxt/src/app/components/layout.ts @@ -1,7 +1,9 @@ -import type { InjectionKey, Ref, VNode } from 'vue' +import type { Ref, VNode } from 'vue' import { Suspense, Transition, computed, defineComponent, h, inject, mergeProps, nextTick, onMounted, provide, ref, unref } from 'vue' import type { RouteLocationNormalizedLoaded } from 'vue-router' import { _wrapIf } from './utils' +import { LayoutMetaSymbol, PageRouteSymbol } from './injections' + import { useRoute } from '#app/composables/router' // @ts-expect-error virtual file import { useRoute as useVueRouterRoute } from '#build/pages' @@ -11,12 +13,6 @@ import layouts from '#build/layouts' import { appLayoutTransition as defaultLayoutTransition } from '#build/nuxt.config.mjs' import { useNuxtApp } from '#app' -export interface LayoutMeta { - isCurrent: (route: RouteLocationNormalizedLoaded) => boolean -} - -export const LayoutMetaSymbol: InjectionKey = Symbol('layout-meta') - export default defineComponent({ name: 'NuxtLayout', inheritAttrs: false, @@ -29,7 +25,7 @@ export default defineComponent({ setup (props, context) { const nuxtApp = useNuxtApp() // Need to ensure (if we are not a child of ``) that we use synchronous route (not deferred) - const injectedRoute = inject('_route') as RouteLocationNormalizedLoaded + const injectedRoute = inject(PageRouteSymbol) const route = injectedRoute === useRoute() ? useVueRouterRoute() : injectedRoute const layout = computed(() => unref(props.name) ?? route.meta.layout as string ?? 'default') @@ -49,13 +45,14 @@ export default defineComponent({ // We avoid rendering layout transition if there is no layout to render return _wrapIf(Transition, hasLayout && transitionProps, { default: () => h(Suspense, { suspensible: true, onResolve: () => { nextTick(done) } }, { - default: () => _wrapIf(LayoutProvider, hasLayout && { + // @ts-expect-error seems to be an issue in vue types + default: () => h(LayoutProvider, { layoutProps: mergeProps(context.attrs, { ref: layoutRef }), key: layout.value, name: layout.value, shouldProvide: !props.name, hasTransition: !!transitionProps - }, context.slots).default() + }, context.slots) }) }).default() } @@ -67,7 +64,7 @@ const LayoutProvider = defineComponent({ inheritAttrs: false, props: { name: { - type: String + type: [String, Boolean] }, layoutProps: { type: Object @@ -81,33 +78,45 @@ const LayoutProvider = defineComponent({ }, setup (props, context) { // Prevent reactivity when the page will be rerendered in a different suspense fork + // eslint-disable-next-line vue/no-setup-props-destructure + const name = props.name 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 + let vnode: VNode | undefined 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.`) + if (name) { + console.warn(`[nuxt] \`${name}\` layout does not have a single root node and will cause errors when navigating between routes.`) + } else { + console.warn('[nuxt] `` needs to be passed a single root node in its default slot.') + } } }) }) } return () => { + if (!name || (typeof name === 'string' && !(name in layouts))) { + if (process.dev && process.client && props.hasTransition) { + vnode = context.slots.default?.() as VNode | undefined + return vnode + } + return context.slots.default?.() + } + if (process.dev && process.client && props.hasTransition) { - vnode = h(layouts[props.name], props.layoutProps, context.slots) + vnode = h(layouts[name], props.layoutProps, context.slots) return vnode } - return h(layouts[props.name], props.layoutProps, context.slots) + return h(layouts[name], props.layoutProps, context.slots) } } }) diff --git a/packages/nuxt/src/app/components/nuxt-root.vue b/packages/nuxt/src/app/components/nuxt-root.vue index 08a9fc669e..c39c0d7041 100644 --- a/packages/nuxt/src/app/components/nuxt-root.vue +++ b/packages/nuxt/src/app/components/nuxt-root.vue @@ -12,6 +12,7 @@ import { defineAsyncComponent, onErrorCaptured, onServerPrefetch, provide } from import { useNuxtApp } from '#app/nuxt' import { isNuxtError, showError, useError } from '#app/composables/error' import { useRoute } from '#app/composables/router' +import { PageRouteSymbol } from '#app/components/injections' import AppComponent from '#build/app-component.mjs' import ErrorComponent from '#build/error-component.mjs' @@ -27,7 +28,7 @@ const SingleRenderer = process.test && process.dev && process.server && url.star .then(r => r.default(process.server ? url : window.location.href))) // Inject default route (outside of pages) as active route -provide('_route', useRoute()) +provide(PageRouteSymbol, useRoute()) // vue:setup hook const results = nuxtApp.hooks.callHookWith(hooks => hooks.map(hook => hook()), 'vue:setup') diff --git a/packages/nuxt/src/app/components/route-provider.ts b/packages/nuxt/src/app/components/route-provider.ts new file mode 100644 index 0000000000..9e283895a9 --- /dev/null +++ b/packages/nuxt/src/app/components/route-provider.ts @@ -0,0 +1,57 @@ +import { computed, defineComponent, h, nextTick, onMounted, provide, reactive } from 'vue' +import type { Ref, VNode } from 'vue' +import type { RouteLocation, RouteLocationNormalizedLoaded } from '#vue-router' +import { PageRouteSymbol } from '#app/components/injections' + +export const RouteProvider = defineComponent({ + name: 'RouteProvider', + props: { + vnode: { + type: Object as () => VNode, + required: true + }, + route: { + type: Object as () => RouteLocationNormalizedLoaded, + required: true + }, + vnodeRef: Object as () => Ref, + renderKey: String, + trackRootNodes: Boolean + }, + setup (props) { + // Prevent reactivity when the page will be rerendered in a different suspense fork + // eslint-disable-next-line vue/no-setup-props-destructure + const previousKey = props.renderKey + // eslint-disable-next-line vue/no-setup-props-destructure + const previousRoute = props.route + + // Provide a reactive route within the page + const route = {} as RouteLocation + for (const key in props.route) { + (route as any)[key] = computed(() => previousKey === props.renderKey ? props.route[key as keyof RouteLocationNormalizedLoaded] : previousRoute[key as keyof RouteLocationNormalizedLoaded]) + } + + provide(PageRouteSymbol, reactive(route)) + + let vnode: VNode + if (process.dev && process.client && props.trackRootNodes) { + 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.vnode, { ref: props.vnodeRef }) + return vnode + } + + return h(props.vnode, { ref: props.vnodeRef }) + } + } +}) diff --git a/packages/nuxt/src/app/composables/router.ts b/packages/nuxt/src/app/composables/router.ts index 84be97eb2c..131fa17858 100644 --- a/packages/nuxt/src/app/composables/router.ts +++ b/packages/nuxt/src/app/composables/router.ts @@ -10,6 +10,7 @@ import { createError, showError } from './error' import { useState } from './state' import type { PageMeta } from '#app' +import { PageRouteSymbol } from '#app/components/injections' export const useRouter: typeof _useRouter = () => { return useNuxtApp()?.$router as Router @@ -20,7 +21,7 @@ export const useRoute: typeof _useRoute = () => { console.warn('[nuxt] Calling `useRoute` within middleware may lead to misleading results. Instead, use the (to, from) arguments passed to the middleware to access the new and old routes.') } if (hasInjectionContext()) { - return inject('_route', useNuxtApp()._route) + return inject(PageRouteSymbol, useNuxtApp()._route) } return useNuxtApp()._route } diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts index 02b065fbb8..b7cebd25a9 100644 --- a/packages/nuxt/src/pages/runtime/page.ts +++ b/packages/nuxt/src/pages/runtime/page.ts @@ -1,14 +1,15 @@ -import { Suspense, Transition, computed, defineComponent, h, inject, nextTick, onMounted, provide, reactive, ref } from 'vue' +import { Suspense, Transition, defineComponent, h, inject, nextTick, ref } from 'vue' import type { KeepAliveProps, TransitionProps, VNode } from 'vue' import { RouterView } from '#vue-router' import { defu } from 'defu' -import type { RouteLocation, RouteLocationNormalized, RouteLocationNormalizedLoaded } from '#vue-router' +import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from '#vue-router' import type { RouterViewSlotProps } from './utils' import { generateRouteKey, wrapInKeepAlive } from './utils' +import { RouteProvider } from '#app/components/route-provider' import { useNuxtApp } from '#app/nuxt' import { _wrapIf } from '#app/components/utils' -import { LayoutMetaSymbol } from '#app/components/layout' +import { LayoutMetaSymbol, PageRouteSymbol } from '#app/components/injections' // @ts-expect-error virtual file import { appKeepalive as defaultKeepaliveConfig, appPageTransition as defaultPageTransition } from '#build/nuxt.config.mjs' @@ -38,6 +39,7 @@ export default defineComponent({ setup (props, { attrs, expose }) { const nuxtApp = useNuxtApp() const pageRef = ref() + const forkRoute = inject(PageRouteSymbol, null) expose({ pageRef }) @@ -47,13 +49,32 @@ export default defineComponent({ return () => { return h(RouterView, { name: props.name, route: props.route, ...attrs }, { default: (routeProps: RouterViewSlotProps) => { - if (!routeProps.Component) { return } + const isRenderingNewRouteInOldFork = process.client && haveParentRoutesRendered(forkRoute, routeProps.route, routeProps.Component) + const hasSameChildren = process.client && forkRoute && forkRoute.matched.length === routeProps.route.matched.length + + if (!routeProps.Component) { + // If we're rendering a `` child route on navigation to a route which lacks a child page + // we'll render the old vnode until the new route finishes resolving + if (process.client && vnode && !hasSameChildren) { + return vnode + } + return + } // Return old vnode if we are rendering _new_ page suspense fork in _old_ layout suspense fork - if (vnode && _layoutMeta && !_layoutMeta.isCurrent(routeProps.route)) { + if (process.client && vnode && _layoutMeta && !_layoutMeta.isCurrent(routeProps.route)) { return vnode } + if (process.client && isRenderingNewRouteInOldFork && forkRoute && (!_layoutMeta || _layoutMeta?.isCurrent(forkRoute))) { + // if leaving a route with an existing child route, render the old vnode + if (hasSameChildren) { + return vnode + } + // If _leaving_ null child route, return null vnode + return null + } + const key = generateRouteKey(routeProps, props.pageKey) const done = nuxtApp.deferHydration() @@ -70,7 +91,17 @@ export default defineComponent({ suspensible: true, onPending: () => nuxtApp.callHook('page:start', routeProps.Component), onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).finally(done)) } - }, { default: () => h(RouteProvider, { key, routeProps, pageKey: key, hasTransition, pageRef } as {}) }) + }, { + // @ts-expect-error seems to be an issue in vue types + default: () => h(RouteProvider, { + key, + vnode: routeProps.Component, + route: routeProps.route, + renderKey: key, + trackRootNodes: hasTransition, + vnodeRef: pageRef + }) + }) )).default() return vnode @@ -92,45 +123,15 @@ function _mergeTransitionProps (routeProps: TransitionProps[]): TransitionProps return defu(..._props as [TransitionProps, TransitionProps]) } -const RouteProvider = defineComponent({ - name: 'RouteProvider', - // TODO: Type props - // eslint-disable-next-line vue/require-prop-types - props: ['routeProps', 'pageKey', 'hasTransition', 'pageRef'], - setup (props) { - // Prevent reactivity when the page will be rerendered in a different suspense fork - // eslint-disable-next-line vue/no-setup-props-destructure - const previousKey = props.pageKey - // eslint-disable-next-line vue/no-setup-props-destructure - const previousRoute = props.routeProps.route +function haveParentRoutesRendered (fork: RouteLocationNormalizedLoaded | null, newRoute: RouteLocationNormalizedLoaded, Component?: VNode) { + if (!fork) { return false } - // Provide a reactive route within the page - const route = {} as RouteLocation - for (const key in props.routeProps.route) { - (route as any)[key] = computed(() => previousKey === props.pageKey ? props.routeProps.route[key] : previousRoute[key]) - } + const index = newRoute.matched.findIndex(m => m.components?.default === Component?.type) + if (!index || index === -1) { return false } - provide('_route', reactive(route)) - - 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, { ref: props.pageRef }) - return vnode - } - - return h(props.routeProps.Component, { ref: props.pageRef }) - } - } -}) + // we only care whether the parent route components have had to rerender + return newRoute.matched.slice(0, index) + .some( + (c, i) => c.components?.default !== fork.matched[i]?.components?.default) || + (Component && generateRouteKey({ route: newRoute, Component }) !== generateRouteKey({ route: fork, Component })) +} diff --git a/test/basic.test.ts b/test/basic.test.ts index 7b28c950e4..da48d3defa 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1037,21 +1037,31 @@ describe('deferred app suspense resolve', () => { }) describe('nested suspense', () => { - const navigations = [ + const navigations = ([ ['/suspense/sync-1/async-1/', '/suspense/sync-2/async-1/'], ['/suspense/sync-1/sync-1/', '/suspense/sync-2/async-1/'], ['/suspense/async-1/async-1/', '/suspense/async-2/async-1/'], ['/suspense/async-1/sync-1/', '/suspense/async-2/async-1/'] - ] + ]).flatMap(([start, end]) => [ + [start, end], + [start, end + '?layout=custom'], + [start + '?layout=custom', end] + ]) it.each(navigations)('should navigate from %s to %s with no white flash', async (start, nav) => { const page = await createPage(start, {}) + const logs: string[] = [] + page.on('console', (msg) => { + const text = msg.text() + if (text.includes('[vite]') || text.includes(' is an experimental feature')) { return } + logs.push(msg.text()) + }) await page.waitForLoadState('networkidle') - const slug = nav.replace(/[/-]+/g, '-') + const slug = nav.replace(/\?.*$/, '').replace(/[/-]+/g, '-') await page.click(`[href^="${nav}"]`) - const text = await page.waitForFunction(slug => document.querySelector(`#${slug}`)?.innerHTML, slug) + const text = await page.waitForFunction(slug => document.querySelector(`main:has(#child${slug})`)?.innerHTML, slug) // @ts-expect-error TODO: fix upstream in playwright - types for evaluate are broken .then(r => r.evaluate(r => r)) @@ -1061,6 +1071,114 @@ describe('nested suspense', () => { // const text = await parent.innerText() expect(text).toContain('Async child: 2 - 1') + expect(text).toContain('parent: 2') + + const first = start.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups! + const last = nav.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups! + + expect(logs.sort()).toEqual([ + // [first load] from parent + `[${first.parentType}]`, + ...first.parentType === 'async' ? ['[async] running async data'] : [], + // [first load] from child + `[${first.parentType}] [${first.childType}]`, + ...first.childType === 'async' ? [`[${first.parentType}] [${first.parentNum}] [async] [${first.childNum}] running async data`] : [], + // [navigation] from parent + `[${last.parentType}]`, + ...last.parentType === 'async' ? ['[async] running async data'] : [], + // [navigation] from child + `[${last.parentType}] [${last.childType}]`, + ...last.childType === 'async' ? [`[${last.parentType}] [${last.parentNum}] [async] [${last.childNum}] running async data`] : [] + ].sort()) + + await page.close() + }) + + const outwardNavigations = [ + ['/suspense/async-2/async-1/', '/suspense/async-1/'], + ['/suspense/async-2/sync-1/', '/suspense/async-1/'] + ] + + it.each(outwardNavigations)('should navigate from %s to a parent %s with no white flash', async (start, nav) => { + const page = await createPage(start, {}) + const logs: string[] = [] + page.on('console', (msg) => { + const text = msg.text() + if (text.includes('[vite]') || text.includes(' is an experimental feature')) { return } + logs.push(msg.text()) + }) + await page.waitForLoadState('networkidle') + + await page.waitForSelector(`main:has(#child${start.replace(/[/-]+/g, '-')})`) + + const slug = start.replace(/[/-]+/g, '-') + await page.click(`[href^="${nav}"]`) + + // wait until child selector disappears and grab HTML of parent + const text = await page.waitForFunction(slug => document.querySelector(`main:not(:has(#child${slug}))`)?.innerHTML, slug) + // @ts-expect-error TODO: fix upstream in playwright - types for evaluate are broken + .then(r => r.evaluate(r => r)) + + expect(text).toContain('Async parent: 1') + + const first = start.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups! + const last = nav.match(/\/suspense\/(?a?sync)-(?\d)\//)!.groups! + + expect(logs.sort()).toEqual([ + // [first load] from parent + `[${first.parentType}]`, + ...first.parentType === 'async' ? ['[async] running async data'] : [], + // [first load] from child + `[${first.parentType}] [${first.childType}]`, + ...first.childType === 'async' ? [`[${first.parentType}] [${first.parentNum}] [async] [${first.childNum}] running async data`] : [], + // [navigation] from parent + `[${last.parentType}]`, + ...last.parentType === 'async' ? ['[async] running async data'] : [] + ].sort()) + + await page.close() + }) + + const inwardNavigations = [ + ['/suspense/async-2/', '/suspense/async-1/async-1/'], + ['/suspense/async-2/', '/suspense/async-1/sync-1/'] + ] + + it.each(inwardNavigations)('should navigate from %s to a child %s with no white flash', async (start, nav) => { + const page = await createPage(start, {}) + const logs: string[] = [] + page.on('console', (msg) => { + const text = msg.text() + if (text.includes('[vite]') || text.includes(' is an experimental feature')) { return } + logs.push(msg.text()) + }) + await page.waitForLoadState('networkidle') + + const slug = nav.replace(/[/-]+/g, '-') + await page.click(`[href^="${nav}"]`) + + // wait until child selector appears and grab HTML of parent + const text = await page.waitForFunction(slug => document.querySelector(`main:has(#child${slug})`)?.innerHTML, slug) + // @ts-expect-error TODO: fix upstream in playwright - types for evaluate are broken + .then(r => r.evaluate(r => r)) + + // const text = await parent.innerText() + expect(text).toContain('Async parent: 1') + + const first = start.match(/\/suspense\/(?a?sync)-(?\d)\//)!.groups! + const last = nav.match(/\/suspense\/(?a?sync)-(?\d)\/(?a?sync)-(?\d)\//)!.groups! + + expect(logs.sort()).toEqual([ + // [first load] from parent + `[${first.parentType}]`, + ...first.parentType === 'async' ? ['[async] running async data'] : [], + // [navigation] from parent + `[${last.parentType}]`, + ...last.parentType === 'async' ? ['[async] running async data'] : [], + // [navigation] from child + `[${last.parentType}] [${last.childType}]`, + ...last.childType === 'async' ? [`[${last.parentType}] [${last.parentNum}] [async] [${last.childNum}] running async data`] : [] + ].sort()) await page.close() }) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 59521916c6..87b06720d5 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const serverDir = join(rootDir, '.output/server') const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.0k"') + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"64.1k"') const modules = await analyzeSizes('node_modules/**/*', serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2329k"') diff --git a/test/fixtures/basic/pages/suspense.vue b/test/fixtures/basic/pages/suspense.vue index 0e082baa04..9621feb431 100644 --- a/test/fixtures/basic/pages/suspense.vue +++ b/test/fixtures/basic/pages/suspense.vue @@ -1,18 +1,42 @@