feat(nuxt): client-only pages (#25037)

This commit is contained in:
Bogdan Kostyuk 2024-03-06 16:38:39 +02:00 committed by GitHub
parent bc44dfc484
commit 230f6b4f19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 161 additions and 6 deletions

View File

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

View 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)
})
])
}
})
}

View File

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

View File

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

View File

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

View 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>

View 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>