mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
feat(nuxt): client-only pages (#25037)
This commit is contained in:
parent
bc44dfc484
commit
230f6b4f19
@ -357,6 +357,10 @@ function navigate(){
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Client-Only Pages
|
||||||
|
|
||||||
|
You can define a page as [client only](/docs/guide/directory-structure/components#client-components) by giving it a `.client.vue` suffix. None of the content of this page will be rendered on the server.
|
||||||
|
|
||||||
## Server-Only Pages
|
## 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.
|
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.
|
||||||
|
19
packages/nuxt/src/components/runtime/client-component.ts
Normal file
19
packages/nuxt/src/components/runtime/client-component.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { defineAsyncComponent, defineComponent, h } from 'vue'
|
||||||
|
import type { AsyncComponentLoader } from 'vue'
|
||||||
|
import { default as ClientOnly } from '#app/components/client-only'
|
||||||
|
|
||||||
|
/*@__NO_SIDE_EFFECTS__*/
|
||||||
|
export const createClientPage = (loader: AsyncComponentLoader) => {
|
||||||
|
const page = defineAsyncComponent(loader)
|
||||||
|
|
||||||
|
return defineComponent({
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup (_, { attrs }) {
|
||||||
|
return () => h('div', [
|
||||||
|
h(ClientOnly, undefined, {
|
||||||
|
default: () => h(page, attrs)
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@ -84,17 +84,21 @@ export async function generateRoutesFromFiles (files: ScannedFile[], options: Ge
|
|||||||
name: '',
|
name: '',
|
||||||
path: '',
|
path: '',
|
||||||
file: file.absolutePath,
|
file: file.absolutePath,
|
||||||
children: []
|
children: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Array where routes should be added, useful when adding child routes
|
// Array where routes should be added, useful when adding child routes
|
||||||
let parent = routes
|
let parent = routes
|
||||||
|
|
||||||
if (segments[segments.length - 1].endsWith('.server')) {
|
const lastSegment = segments[segments.length - 1]
|
||||||
segments[segments.length - 1] = segments[segments.length - 1].replace('.server', '')
|
if (lastSegment.endsWith('.server')) {
|
||||||
|
segments[segments.length - 1] = lastSegment.replace('.server', '')
|
||||||
if (options.shouldUseServerComponents) {
|
if (options.shouldUseServerComponents) {
|
||||||
route.mode = 'server'
|
route.mode = 'server'
|
||||||
}
|
}
|
||||||
|
} else if (lastSegment.endsWith('.client')) {
|
||||||
|
segments[segments.length - 1] = lastSegment.replace('.client', '')
|
||||||
|
route.mode = 'client'
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
for (let i = 0; i < segments.length; i++) {
|
||||||
@ -451,7 +455,9 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> =
|
|||||||
redirect: `${metaImportName}?.redirect`,
|
redirect: `${metaImportName}?.redirect`,
|
||||||
component: page.mode === 'server'
|
component: page.mode === 'server'
|
||||||
? `() => createIslandPage(${route.name})`
|
? `() => createIslandPage(${route.name})`
|
||||||
: genDynamicImport(file, { interopDefault: true })
|
: page.mode === 'client'
|
||||||
|
? `() => createClientPage(${genDynamicImport(file, { interopDefault: true })})`
|
||||||
|
: genDynamicImport(file, { interopDefault: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page.mode === 'server') {
|
if (page.mode === 'server') {
|
||||||
@ -461,6 +467,13 @@ async function createIslandPage (name) {
|
|||||||
_createIslandPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/server-component'))}).then(r => r.createIslandPage)
|
_createIslandPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/server-component'))}).then(r => r.createIslandPage)
|
||||||
return _createIslandPage(name)
|
return _createIslandPage(name)
|
||||||
};`)
|
};`)
|
||||||
|
} else if (page.mode === 'client') {
|
||||||
|
metaImports.add(`
|
||||||
|
let _createClientPage
|
||||||
|
async function createClientPage(loader) {
|
||||||
|
_createClientPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/client-component'))}).then(r => r.createClientPage)
|
||||||
|
return _createClientPage(loader);
|
||||||
|
}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.children != null) {
|
if (route.children != null) {
|
||||||
|
@ -22,7 +22,7 @@ export type TSReference = { types: string } | { path: string }
|
|||||||
export type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'
|
export type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'
|
||||||
|
|
||||||
// If the user does not have `@vue/language-core` installed, VueCompilerOptions will be typed as `any`,
|
// If the user does not have `@vue/language-core` installed, VueCompilerOptions will be typed as `any`,
|
||||||
// thus making the whole `VueTSConfig` type `any`. We only augment TSConfig if VueCompilerOptions is available.
|
// thus making the whole `VueTSConfig` type `any`. We only augment TSConfig if VueCompilerOptions is available.
|
||||||
export type VueTSConfig = 0 extends 1 & VueCompilerOptions ? TSConfig : TSConfig & { vueCompilerOptions?: VueCompilerOptions }
|
export type VueTSConfig = 0 extends 1 & VueCompilerOptions ? TSConfig : TSConfig & { vueCompilerOptions?: VueCompilerOptions }
|
||||||
|
|
||||||
export type NuxtPage = {
|
export type NuxtPage = {
|
||||||
@ -39,9 +39,11 @@ export type NuxtPage = {
|
|||||||
* `all` means the page will be rendered isomorphically - with JavaScript both on client and server.
|
* `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.
|
* `server` means pages are automatically rendered with server components, so there will be no JavaScript to render the page in your client bundle.
|
||||||
|
*
|
||||||
|
* `client` means that page will render on the client-side only.
|
||||||
* @default 'all'
|
* @default 'all'
|
||||||
*/
|
*/
|
||||||
mode?: 'server' | 'all'
|
mode?: 'client' | 'server' | 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NuxtMiddleware = {
|
export type NuxtMiddleware = {
|
||||||
|
@ -458,6 +458,74 @@ describe('pages', () => {
|
|||||||
expect(response).not.toContain('don\'t look at this')
|
expect(response).not.toContain('don\'t look at this')
|
||||||
expect(response).toContain('OH NNNNNNOOOOOOOOOOO')
|
expect(response).toContain('OH NNNNNNOOOOOOOOOOO')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('client only page', async () => {
|
||||||
|
const response = await fetch('/client-only').then(r => r.text())
|
||||||
|
|
||||||
|
// Should not contain rendered page on initial request
|
||||||
|
expect(response).not.toContain('"hasAccessToWindow": true')
|
||||||
|
expect(response).not.toContain('"isServer": false')
|
||||||
|
|
||||||
|
const errors: string[] = []
|
||||||
|
const { page: clientInitialPage } = await renderPage('/client-only-page')
|
||||||
|
|
||||||
|
clientInitialPage.on('console', (message) => {
|
||||||
|
const type = message.type()
|
||||||
|
if (type === 'error' || type === 'warning') {
|
||||||
|
errors.push(message.text())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// But after hydration element should appear and contain this object
|
||||||
|
expect(await clientInitialPage.locator('#state').textContent()).toMatchInlineSnapshot(`
|
||||||
|
"{
|
||||||
|
"hasAccessToWindow": true,
|
||||||
|
"isServer": false
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
|
||||||
|
expect(await clientInitialPage.locator('#server-rendered').textContent()).toMatchInlineSnapshot(`"false"`)
|
||||||
|
|
||||||
|
// Then go to non client only page
|
||||||
|
await clientInitialPage.click('a')
|
||||||
|
await new Promise((r) => setTimeout(r, 50)) // little delay to finish transition
|
||||||
|
|
||||||
|
// that page should be client rendered
|
||||||
|
expect(await clientInitialPage.locator('#server-rendered').textContent()).toMatchInlineSnapshot(`"false"`)
|
||||||
|
// and not contain any errors or warnings
|
||||||
|
expect(errors.length).toBe(0)
|
||||||
|
|
||||||
|
await clientInitialPage.close()
|
||||||
|
errors.length = 0
|
||||||
|
|
||||||
|
const { page: normalInitialPage } = await renderPage('/client-only-page/normal')
|
||||||
|
|
||||||
|
normalInitialPage.on('console', (message) => {
|
||||||
|
const type = message.type()
|
||||||
|
if (type === 'error' || type === 'warning') {
|
||||||
|
errors.push(message.text())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Now non client only page should be sever rendered
|
||||||
|
expect(await normalInitialPage.locator('#server-rendered').textContent()).toMatchInlineSnapshot(`"true"`)
|
||||||
|
|
||||||
|
// Go to client only page
|
||||||
|
await normalInitialPage.click('a')
|
||||||
|
|
||||||
|
// and expect same object to be present
|
||||||
|
expect(await normalInitialPage.locator('#state').textContent()).toMatchInlineSnapshot(`
|
||||||
|
"{
|
||||||
|
"hasAccessToWindow": true,
|
||||||
|
"isServer": false
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
|
||||||
|
// also there should not be any errors
|
||||||
|
expect(errors.length).toBe(0)
|
||||||
|
|
||||||
|
await normalInitialPage.close()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('nuxt composables', () => {
|
describe('nuxt composables', () => {
|
||||||
|
34
test/fixtures/basic/pages/client-only-page/index.client.vue
vendored
Normal file
34
test/fixtures/basic/pages/client-only-page/index.client.vue
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const state = useState('test', () => {
|
||||||
|
let hasAccessToWindow = null as null | boolean
|
||||||
|
|
||||||
|
try {
|
||||||
|
hasAccessToWindow = Object.keys(window).at(0) ? true : false
|
||||||
|
} catch {
|
||||||
|
hasAccessToWindow = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAccessToWindow,
|
||||||
|
isServer: import.meta.server
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverRendered = useState(() => import.meta.server)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/client-only-page/normal">
|
||||||
|
normal
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<p id="state">
|
||||||
|
{{ state }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p id="server-rendered">
|
||||||
|
{{ serverRendered }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
15
test/fixtures/basic/pages/client-only-page/normal.vue
vendored
Normal file
15
test/fixtures/basic/pages/client-only-page/normal.vue
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const renderedOnServer = useState(() => import.meta.server)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtLink to="/client-only-page">
|
||||||
|
to client only page
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<p id="server-rendered">
|
||||||
|
{{ renderedOnServer }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
Loading…
Reference in New Issue
Block a user