diff --git a/docs/content/3.api/1.composables/set-layout.md b/docs/content/3.api/1.composables/set-layout.md new file mode 100644 index 00000000000..17973577d28 --- /dev/null +++ b/docs/content/3.api/1.composables/set-layout.md @@ -0,0 +1,16 @@ +# `setLayout` + +`setLayout` allows you to dynamically change the layout of a page. + +`setLayout` relies on access to the Nuxt context and can only be called within component setup functions, plugins, and route middleware. + +```js +export default defineNuxtRouteMiddleware(to => { + // Set the layout on the route you are navigating _to_ + setLayout('other') +}) +``` + +::alert{icon=👉} +If you choose to set the layout dynamically on the server-side, you _must_ do so before the layout is rendered by Vue. (In other words, within a plugin or route middleware.) Otherwise there will be a hydration mismatch. +:: diff --git a/examples/routing/layouts/middleware/other.ts b/examples/routing/layouts/middleware/other.ts new file mode 100644 index 00000000000..279f8eb920a --- /dev/null +++ b/examples/routing/layouts/middleware/other.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default defineNuxtRouteMiddleware(() => { + setLayout('other') +}) diff --git a/examples/routing/layouts/pages/index.vue b/examples/routing/layouts/pages/index.vue index e66905fb951..fac4b038a02 100644 --- a/examples/routing/layouts/pages/index.vue +++ b/examples/routing/layouts/pages/index.vue @@ -11,6 +11,18 @@ Dynamic layout + + Other layout + + + Change to default layout + + + Change to custom layout + + + Change to other layout + diff --git a/examples/routing/layouts/pages/other.vue b/examples/routing/layouts/pages/other.vue new file mode 100644 index 00000000000..00b14af93a6 --- /dev/null +++ b/examples/routing/layouts/pages/other.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index b3a31a9f43d..0b4b3f16115 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -10,6 +10,6 @@ export type { FetchResult, UseFetchOptions } from './fetch' export { useCookie } from './cookie' export type { CookieOptions, CookieRef } from './cookie' export { useRequestHeaders, useRequestEvent, setResponseStatus } from './ssr' -export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, navigateTo, useRoute, useActiveRoute, useRouter } from './router' +export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, setLayout, navigateTo, useRoute, useActiveRoute, useRouter } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' export { preloadComponents, prefetchComponents } from './preload' diff --git a/packages/nuxt/src/app/composables/router.ts b/packages/nuxt/src/app/composables/router.ts index 188d8c0bca9..542eb8aab7b 100644 --- a/packages/nuxt/src/app/composables/router.ts +++ b/packages/nuxt/src/app/composables/router.ts @@ -2,7 +2,7 @@ import { getCurrentInstance, inject } from 'vue' import type { Router, RouteLocationNormalizedLoaded, NavigationGuard, RouteLocationNormalized, RouteLocationRaw, NavigationFailure, RouteLocationPathRaw } from 'vue-router' import { sendRedirect } from 'h3' import { hasProtocol, joinURL, parseURL } from 'ufo' -import { useNuxtApp, useRuntimeConfig } from '#app' +import { useNuxtApp, useRuntimeConfig, useState } from '#app' export const useRouter = () => { return useNuxtApp()?.$router as Router @@ -114,3 +114,17 @@ export const abortNavigation = (err?: Error | string) => { } return false } + +export const setLayout = (layout: string) => { + if (process.server) { + useState('_layout').value = layout + } + if (isProcessingMiddleware()) { + const unsubscribe = useRouter().beforeResolve((to) => { + to.meta.layout = layout + unsubscribe() + }) + } else { + useRoute().meta.layout = layout + } +} diff --git a/packages/nuxt/src/app/plugins/router.ts b/packages/nuxt/src/app/plugins/router.ts index 18954954549..64bf5d50a6c 100644 --- a/packages/nuxt/src/app/plugins/router.ts +++ b/packages/nuxt/src/app/plugins/router.ts @@ -1,7 +1,7 @@ import { reactive, h } from 'vue' import { parseURL, stringifyParsedURL, parseQuery, stringifyQuery, withoutBase, isEqual, joinURL } from 'ufo' import { createError } from 'h3' -import { defineNuxtPlugin, clearError, navigateTo, showError, useRuntimeConfig } from '..' +import { defineNuxtPlugin, clearError, navigateTo, showError, useRuntimeConfig, useState } from '..' import { callWithNuxt } from '../nuxt' // @ts-ignore import { globalMiddleware } from '#build/middleware' @@ -218,9 +218,13 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>((nuxtApp) => { named: {} } + const initialLayout = useState('_layout') nuxtApp.hooks.hookOnce('app:created', async () => { router.beforeEach(async (to, from) => { to.meta = reactive(to.meta || {}) + if (nuxtApp.isHydrating) { + to.meta.layout = initialLayout.value ?? to.meta.layout + } nuxtApp._processingMiddleware = true const middlewareEntries = new Set([...globalMiddleware, ...nuxtApp._middleware.global]) diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 6985af5b27e..f2838776f3c 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -25,6 +25,7 @@ const appPreset = defineUnimportPreset({ 'useAsyncData', 'useLazyAsyncData', 'refreshNuxtData', + 'setLayout', 'defineNuxtComponent', 'useNuxtApp', 'defineNuxtPlugin', diff --git a/packages/nuxt/src/pages/runtime/router.ts b/packages/nuxt/src/pages/runtime/router.ts index cb12db1f574..32163890039 100644 --- a/packages/nuxt/src/pages/runtime/router.ts +++ b/packages/nuxt/src/pages/runtime/router.ts @@ -9,7 +9,7 @@ import { import { createError } from 'h3' import { withoutBase, isEqual } from 'ufo' import NuxtPage from './page' -import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, showError, clearError, navigateTo, useError } from '#app' +import { callWithNuxt, defineNuxtPlugin, useRuntimeConfig, showError, clearError, navigateTo, useError, useState } from '#app' // @ts-ignore import routes from '#build/routes' // @ts-ignore @@ -114,8 +114,12 @@ export default defineNuxtPlugin(async (nuxtApp) => { callWithNuxt(nuxtApp, showError, [error]) } + const initialLayout = useState('_layout') router.beforeEach(async (to, from) => { to.meta = reactive(to.meta) + if (nuxtApp.isHydrating) { + to.meta.layout = initialLayout.value ?? to.meta.layout + } nuxtApp._processingMiddleware = true type MiddlewareDef = string | NavigationGuard diff --git a/test/basic.test.ts b/test/basic.test.ts index 893f40ef7ce..497aa9c6425 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -273,6 +273,16 @@ describe('layouts', () => { expect(html).toContain('with-layout.vue') expect(html).toContain('Custom Layout:') }) + it('should work with a dynamically set layout', async () => { + const html = await $fetch('/with-dynamic-layout') + + // Snapshot + // expect(html).toMatchInlineSnapshot() + + expect(html).toContain('with-dynamic-layout') + expect(html).toContain('Custom Layout:') + await expectNoClientErrors('/with-dynamic-layout') + }) }) describe('reactivity transform', () => { diff --git a/test/fixtures/basic/middleware/sets-layout.ts b/test/fixtures/basic/middleware/sets-layout.ts new file mode 100644 index 00000000000..52c52f21f74 --- /dev/null +++ b/test/fixtures/basic/middleware/sets-layout.ts @@ -0,0 +1,4 @@ +export default defineNuxtRouteMiddleware(async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + setLayout('custom') +}) diff --git a/test/fixtures/basic/pages/with-dynamic-layout.vue b/test/fixtures/basic/pages/with-dynamic-layout.vue new file mode 100644 index 00000000000..6e2d5895489 --- /dev/null +++ b/test/fixtures/basic/pages/with-dynamic-layout.vue @@ -0,0 +1,11 @@ + + +