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>
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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: '',
|
||||
path: '',
|
||||
file: file.absolutePath,
|
||||
children: []
|
||||
children: [],
|
||||
}
|
||||
|
||||
// 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', '')
|
||||
const lastSegment = segments[segments.length - 1]
|
||||
if (lastSegment.endsWith('.server')) {
|
||||
segments[segments.length - 1] = lastSegment.replace('.server', '')
|
||||
if (options.shouldUseServerComponents) {
|
||||
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++) {
|
||||
@ -451,6 +455,8 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> =
|
||||
redirect: `${metaImportName}?.redirect`,
|
||||
component: page.mode === 'server'
|
||||
? `() => createIslandPage(${route.name})`
|
||||
: page.mode === 'client'
|
||||
? `() => createClientPage(${genDynamicImport(file, { interopDefault: true })})`
|
||||
: genDynamicImport(file, { interopDefault: true })
|
||||
}
|
||||
|
||||
@ -461,6 +467,13 @@ async function createIslandPage (name) {
|
||||
_createIslandPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/server-component'))}).then(r => r.createIslandPage)
|
||||
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) {
|
||||
|
@ -39,9 +39,11 @@ export type NuxtPage = {
|
||||
* `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.
|
||||
*
|
||||
* `client` means that page will render on the client-side only.
|
||||
* @default 'all'
|
||||
*/
|
||||
mode?: 'server' | 'all'
|
||||
mode?: 'client' | 'server' | 'all'
|
||||
}
|
||||
|
||||
export type NuxtMiddleware = {
|
||||
|
@ -458,6 +458,74 @@ describe('pages', () => {
|
||||
expect(response).not.toContain('don\'t look at this')
|
||||
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', () => {
|
||||
|
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