diff --git a/docs/2.guide/2.directory-structure/1.pages.md b/docs/2.guide/2.directory-structure/1.pages.md index 2332295f8a..851018ba3e 100644 --- a/docs/2.guide/2.directory-structure/1.pages.md +++ b/docs/2.guide/2.directory-structure/1.pages.md @@ -357,6 +357,10 @@ function navigate(){ ``` +## 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. diff --git a/packages/nuxt/src/components/runtime/client-component.ts b/packages/nuxt/src/components/runtime/client-component.ts new file mode 100644 index 0000000000..c7b68e6660 --- /dev/null +++ b/packages/nuxt/src/components/runtime/client-component.ts @@ -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) + }) + ]) + } + }) +} diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 0eebfc963a..3a33b5aa57 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -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,7 +455,9 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = redirect: `${metaImportName}?.redirect`, component: page.mode === 'server' ? `() => createIslandPage(${route.name})` - : genDynamicImport(file, { interopDefault: true }) + : page.mode === 'client' + ? `() => createClientPage(${genDynamicImport(file, { interopDefault: true })})` + : genDynamicImport(file, { interopDefault: true }) } 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) 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) { diff --git a/packages/schema/src/types/hooks.ts b/packages/schema/src/types/hooks.ts index 51e06f82d9..6a33051266 100644 --- a/packages/schema/src/types/hooks.ts +++ b/packages/schema/src/types/hooks.ts @@ -22,7 +22,7 @@ export type TSReference = { types: string } | { path: string } export type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' // 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 NuxtPage = { @@ -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 = { diff --git a/test/basic.test.ts b/test/basic.test.ts index e18a38133d..065639d32b 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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', () => { diff --git a/test/fixtures/basic/pages/client-only-page/index.client.vue b/test/fixtures/basic/pages/client-only-page/index.client.vue new file mode 100644 index 0000000000..2cad091fab --- /dev/null +++ b/test/fixtures/basic/pages/client-only-page/index.client.vue @@ -0,0 +1,34 @@ + + + diff --git a/test/fixtures/basic/pages/client-only-page/normal.vue b/test/fixtures/basic/pages/client-only-page/normal.vue new file mode 100644 index 0000000000..6781a8d3e2 --- /dev/null +++ b/test/fixtures/basic/pages/client-only-page/normal.vue @@ -0,0 +1,15 @@ + + +