feat(nuxt): server-only pages (#24954)

This commit is contained in:
Julien Huang 2024-02-26 18:39:26 +01:00 committed by GitHub
parent c05239901a
commit 196223c0fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 109 additions and 5 deletions

View File

@ -357,13 +357,21 @@ function navigate(){
</script>
```
## 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.

View File

@ -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 | typeof NuxtIsland>(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)
])
}
}
})
}

View File

@ -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')
}

View File

@ -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))

View File

@ -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<NuxtPage[]> {
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<NuxtPage[]> {
type GenerateRoutesFromFilesOptions = {
shouldExtractBuildMeta?: boolean
shouldUseServerComponents?: boolean
vfs?: Record<string, string>
}
@ -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<string> =
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) {

View File

@ -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 })

View File

@ -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 = {

View File

@ -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', () => {

View File

@ -86,6 +86,9 @@
<NuxtLink to="/big-page-1">
to big 1
</NuxtLink>
<NuxtLink to="/server-page">
to server page
</NuxtLink>
</div>
</template>

View File

@ -0,0 +1,10 @@
<template>
<div id="server-page">
Hello this is a server page
<NuxtLink
to="/"
>
to home
</NuxtLink>
</div>
</template>