diff --git a/docs/2.guide/2.directory-structure/1.pages.md b/docs/2.guide/2.directory-structure/1.pages.md index 052548c97d..2332295f8a 100644 --- a/docs/2.guide/2.directory-structure/1.pages.md +++ b/docs/2.guide/2.directory-structure/1.pages.md @@ -357,13 +357,21 @@ function navigate(){ ``` -## Custom routing +## Server-Only Pages + +You can define a page as [server only](/docs/guide/directory-structure/components#server-components) by giving it a `.server.vue` suffix. While you will be able to navigate to the page using client-side navigation, controlled by `vue-router`, it will be rendered with a server component automatically, meaning the code required to render the page will not be in your client-side bundle. + +::note +You will also need to enable `experimental.componentIslands` in order to make this possible. +:: + +## Custom Routing As your app gets bigger and more complex, your routing might require more flexibility. For this reason, Nuxt directly exposes the router, routes and router options for customization in different ways. :read-more{to="/docs/guide/going-further/custom-routing"} -## Multiple pages directories +## Multiple Pages Directories By default, all your pages should be in one `pages` directory at the root of your project. diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 3a2463b09d..e88118149f 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -1,5 +1,7 @@ import { defineComponent, h, ref } from 'vue' import NuxtIsland from '#app/components/nuxt-island' +import { useRoute } from '#app/composables/router' +import { isPrerendered } from '#app/composables/payload' /*@__NO_SIDE_EFFECTS__*/ export const createServerComponent = (name: string) => { @@ -25,3 +27,33 @@ export const createServerComponent = (name: string) => { } }) } + +/*@__NO_SIDE_EFFECTS__*/ +export const createIslandPage = (name: string) => { + return defineComponent({ + name, + inheritAttrs: false, + props: { lazy: Boolean }, + async setup (props, { slots, expose }) { + const islandRef = ref(null) + + expose({ + refresh: () => islandRef.value?.refresh() + }) + + const route = useRoute() + const path = await isPrerendered(route.path) ? route.path : route.fullPath.replace(/#.*$/, '') + + return () => { + return h('div', [ + h(NuxtIsland, { + name: `page:${name}`, + lazy: props.lazy, + ref: islandRef, + context: { url: path } + }, slots) + ]) + } + } + }) +} diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index 22e845de7e..5d751a705d 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -72,12 +72,17 @@ export const componentsIslandsTemplate: NuxtTemplate = { // components.islands.mjs' getContents ({ app }) { const components = app.components + const pages = app.pages const islands = components.filter(component => component.island || // .server components without a corresponding .client component will need to be rendered as an island (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client')) ) + const pageExports = pages?.filter(p => (p.mode === 'server' && p.file && p.name)).map((p) => { + return `"page:${p.name}": defineAsyncComponent(${genDynamicImport(p.file!)}.then(c => c.default || c))` + }) || [] + return [ 'import { defineAsyncComponent } from \'vue\'', 'export const islandComponents = import.meta.client ? {} : {', @@ -87,7 +92,7 @@ export const componentsIslandsTemplate: NuxtTemplate = { const comment = createImportMagicComments(c) return ` "${c.pascalName}": defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` } - ).join(',\n'), + ).concat(pageExports).join(',\n'), '}' ].join('\n') } diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index 8c74482f05..51f2e31630 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -400,6 +400,10 @@ export default defineNuxtModule({ const sourceFiles = nuxt.apps.default?.pages?.length ? getSources(nuxt.apps.default.pages) : [] for (const key in manifest) { + if (manifest[key].src && Object.values(nuxt.apps).some(app => app.pages?.some(page => page.mode === 'server' && page.file === join(nuxt.options.srcDir, manifest[key].src!) ))) { + delete manifest[key] + continue + } if (manifest[key].isEntry) { manifest[key].dynamicImports = manifest[key].dynamicImports?.filter(i => !sourceFiles.includes(i)) diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 9a88d3d4ab..0eebfc963a 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -14,6 +14,7 @@ import type { NuxtPage } from 'nuxt/schema' import { uniqueBy } from '../core/utils' import { toArray } from '../utils' +import { distDir } from '../dirs' enum SegmentParserState { initial, @@ -58,6 +59,7 @@ export async function resolvePagesRoutes (): Promise { const allRoutes = await generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), { shouldExtractBuildMeta: nuxt.options.experimental.scanPageMeta || nuxt.options.experimental.typedPages, + shouldUseServerComponents: !!nuxt.options.experimental.componentIslands, vfs: nuxt.vfs }) @@ -66,6 +68,7 @@ export async function resolvePagesRoutes (): Promise { type GenerateRoutesFromFilesOptions = { shouldExtractBuildMeta?: boolean + shouldUseServerComponents?: boolean vfs?: Record } @@ -87,6 +90,13 @@ export async function generateRoutesFromFiles (files: ScannedFile[], options: Ge // Array where routes should be added, useful when adding child routes let parent = routes + if (segments[segments.length - 1].endsWith('.server')) { + segments[segments.length - 1] = segments[segments.length - 1].replace('.server', '') + if (options.shouldUseServerComponents) { + route.mode = 'server' + } + } + for (let i = 0; i < segments.length; i++) { const segment = segments[i] @@ -439,7 +449,18 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = meta: `${metaImportName} || {}`, alias: `${metaImportName}?.alias || []`, redirect: `${metaImportName}?.redirect`, - component: genDynamicImport(file, { interopDefault: true }) + component: page.mode === 'server' + ? `() => createIslandPage(${route.name})` + : genDynamicImport(file, { interopDefault: true }) + } + + if (page.mode === 'server') { + metaImports.add(` +let _createIslandPage +async function createIslandPage (name) { + _createIslandPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/server-component'))}).then(r => r.createIslandPage) + return _createIslandPage(name) +};`) } if (route.children != null) { diff --git a/packages/nuxt/test/pages.test.ts b/packages/nuxt/test/pages.test.ts index dd1c2239ee..1c77fdab76 100644 --- a/packages/nuxt/test/pages.test.ts +++ b/packages/nuxt/test/pages.test.ts @@ -17,7 +17,7 @@ describe('pages:generateRoutesFromFiles', () => { }, } }) - + const tests: Array<{ description: string files?: Array<{ path: string; template?: string; }> @@ -570,6 +570,7 @@ describe('pages:generateRoutesFromFiles', () => { try { result = await generateRoutesFromFiles(test.files.map(file => ({ + shouldUseServerComponents: true, absolutePath: file.path, relativePath: file.path.replace(/^(pages|layer\/pages)\//, '') })), { shouldExtractBuildMeta: true, vfs }) diff --git a/packages/schema/src/types/hooks.ts b/packages/schema/src/types/hooks.ts index 5fe595d314..72d74aab67 100644 --- a/packages/schema/src/types/hooks.ts +++ b/packages/schema/src/types/hooks.ts @@ -28,6 +28,15 @@ export type NuxtPage = { alias?: string[] | string redirect?: RouteLocationRaw children?: NuxtPage[] + /** + * Set the render mode. + * + * `all` means the page will be rendered isomorphically - with JavaScript both on client and server. + * + * `server` means pages are automatically rendered with server components, so there will be no JavaScript to render the page in your client bundle. + * @default 'all' + */ + mode?: 'server' | 'all' } export type NuxtMiddleware = { diff --git a/test/basic.test.ts b/test/basic.test.ts index 1799771885..890e22ab65 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -2090,6 +2090,17 @@ describe('component islands', () => { await startServer() }) + + it('render island page', async () => { + const { page } = await renderPage('/') + + const islandPageRequest = page.waitForRequest((req) => { + return req.url().includes('/__nuxt_island/page:server-page') + }) + await page.getByText('to server page').click() + await islandPageRequest + await page.locator('#server-page').waitFor() + }) }) describe.runIf(isDev() && !isWebpack)('vite plugins', () => { diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index 5c97574bff..04d618f955 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -86,6 +86,9 @@ to big 1 + + to server page + diff --git a/test/fixtures/basic/pages/server-page.server.vue b/test/fixtures/basic/pages/server-page.server.vue new file mode 100644 index 0000000000..398f9958d2 --- /dev/null +++ b/test/fixtures/basic/pages/server-page.server.vue @@ -0,0 +1,10 @@ +