From 2d013c5fadfe57828a67b18fa3154ecc2c6d8401 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Jan 2023 11:20:33 +0000 Subject: [PATCH] feat(nuxt): server-only components (#9972) --- .../2.directory-structure/1.components.md | 47 ++++++++++++++++++- .../components/ServerOnlyComponent.server.vue | 21 +++++++++ packages/nuxt/src/components/loader.ts | 30 ++++++++++-- packages/nuxt/src/components/module.ts | 6 ++- .../components/runtime/server-component.ts | 16 +++++++ packages/nuxt/src/components/templates.ts | 8 +++- test/basic.test.ts | 2 + .../components/ServerOnlyComponent.server.vue | 5 ++ test/fixtures/basic/pages/index.vue | 1 + 9 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 examples/auto-imports/components/components/ServerOnlyComponent.server.vue create mode 100644 packages/nuxt/src/components/runtime/server-component.ts create mode 100644 test/fixtures/basic/components/ServerOnlyComponent.server.vue diff --git a/docs/content/1.docs/2.guide/2.directory-structure/1.components.md b/docs/content/1.docs/2.guide/2.directory-structure/1.components.md index da003f2aa7..05cb5a653e 100644 --- a/docs/content/1.docs/2.guide/2.directory-structure/1.components.md +++ b/docs/content/1.docs/2.guide/2.directory-structure/1.components.md @@ -210,7 +210,48 @@ This feature only works with Nuxt auto-imports and `#components` imports. Explic ## .server Components -`.server` components are fallback components of `.client` components. +`.server` components can either be used on their own or paired with a `.client` component. + +### Standalone server components + +::StabilityEdge + +Standalone server components will always be rendered on the server. When their props update, this will result in a network request that will update the rendered HTML in-place. + +Server components are currently experimental and in order to use them, you need to enable the 'component islands' feature in your nuxt.config: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + experimental: { + componentIslands: true + } +}) +``` + +Now you can register server-only components with the `.server` suffix and use them anywhere in your application automatically. + +```bash +| components/ +--| HighlightedMarkdown.server.vue +``` + +```html{}[pages/example.vue] + +``` + +:: + +### Paired with a `.client` component + +In this case, the `.server` + `.client` components are two 'halves' of a component and can be used in advanced use cases for separate implementations of a component on server and client side. ```bash | components/ @@ -227,6 +268,10 @@ This feature only works with Nuxt auto-imports and `#components` imports. Explic ``` +::alert{type=warning} +It is essential that the client half of the component can 'hydrate' the server-rendered HTML. That is, it should render the same HTML on initial load, or you will experience a hydration mismatch. +:: + ## `` Component Nuxt provides the `` component to render a component only during development. diff --git a/examples/auto-imports/components/components/ServerOnlyComponent.server.vue b/examples/auto-imports/components/components/ServerOnlyComponent.server.vue new file mode 100644 index 0000000000..8aa6a2035d --- /dev/null +++ b/examples/auto-imports/components/components/ServerOnlyComponent.server.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/packages/nuxt/src/components/loader.ts b/packages/nuxt/src/components/loader.ts index d14b870246..d0c81c939d 100644 --- a/packages/nuxt/src/components/loader.ts +++ b/packages/nuxt/src/components/loader.ts @@ -1,4 +1,4 @@ -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { createUnplugin } from 'unplugin' import { parseQuery, parseURL } from 'ufo' import type { Component, ComponentsOptions } from '@nuxt/schema' @@ -11,6 +11,7 @@ interface LoaderOptions { mode: 'server' | 'client' sourcemap?: boolean transform?: ComponentsOptions['transform'] + experimentalComponentIslands?: boolean } function isVueTemplate (id: string) { @@ -43,6 +44,7 @@ function isVueTemplate (id: string) { export const loaderPlugin = createUnplugin((options: LoaderOptions) => { const exclude = options.transform?.exclude || [] const include = options.transform?.include || [] + const serverComponentRuntime = fileURLToPath(new URL('./runtime/server-component', import.meta.url)) return { name: 'nuxt:components-loader', @@ -65,12 +67,23 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { const s = new MagicString(code) // replace `_resolveComponent("...")` to direct import - s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?([^'"]*?)["'][\s,]*[^)]*\)/g, (full, lazy, name) => { + s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?([^'"]*?)["'][\s,]*[^)]*\)/g, (full: string, lazy: string, name: string) => { const component = findComponent(components, name, options.mode) if (component) { let identifier = map.get(component) || `__nuxt_component_${num++}` map.set(component, identifier) + const isServerOnly = component.mode === 'server' && + !components.some(c => c.pascalName === component.pascalName && c.mode === 'client') + if (isServerOnly) { + imports.add(genImport(serverComponentRuntime, [{ name: 'createServerComponent' }])) + imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(name)})`) + if (!options.experimentalComponentIslands) { + console.warn(`Standalone server components (\`${name}\`) are not yet supported without enabling \`experimental.componentIslands\`.`) + } + return identifier + } + const isClientOnly = component.mode === 'client' if (isClientOnly) { imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) @@ -114,9 +127,16 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { function findComponent (components: Component[], name: string, mode: LoaderOptions['mode']) { const id = pascalCase(name).replace(/["']/g, '') + // Prefer exact match const component = components.find(component => id === component.pascalName && ['all', mode, undefined].includes(component.mode)) - if (!component && components.some(component => id === component.pascalName)) { - return components.find(component => component.pascalName === 'ServerPlaceholder') + if (component) { return component } + + // Render client-only components on the server with (a simple div) + if (mode === 'server' && !component) { + return components.find(c => c.pascalName === 'ServerPlaceholder') } - return component + + // Return the other-mode component in all other cases - we'll handle createClientOnly + // and createServerComponent above + return components.find(component => id === component.pascalName) } diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 493e65dcf9..4e2aa4fa08 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -196,7 +196,8 @@ export default defineNuxtModule({ config.plugins.push(loaderPlugin.vite({ sourcemap: nuxt.options.sourcemap[mode], getComponents, - mode + mode, + experimentalComponentIslands: nuxt.options.experimental.componentIslands })) if (nuxt.options.experimental.treeshakeClientOnly && isServer) { config.plugins.push(TreeShakeTemplatePlugin.vite({ @@ -212,7 +213,8 @@ export default defineNuxtModule({ config.plugins.push(loaderPlugin.webpack({ sourcemap: nuxt.options.sourcemap[mode], getComponents, - mode + mode, + experimentalComponentIslands: nuxt.options.experimental.componentIslands })) if (nuxt.options.experimental.treeshakeClientOnly && mode === 'server') { config.plugins.push(TreeShakeTemplatePlugin.webpack({ diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts new file mode 100644 index 0000000000..6e759be158 --- /dev/null +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -0,0 +1,16 @@ +import { defineComponent, h } from 'vue' +// @ts-expect-error virtual import +import { NuxtIsland } from '#components' + +export const createServerComponent = (name: string) => { + return defineComponent({ + name, + inheritAttrs: false, + setup (_props, { attrs }) { + return () => h(NuxtIsland, { + name, + props: attrs + }) + } + }) +} diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index ee5cbf2892..5a714f7da5 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -86,7 +86,13 @@ export const componentsTemplate: NuxtTemplate = { export const componentsIslandsTemplate: NuxtTemplate = { // components.islands.mjs' getContents ({ options }) { - return options.getComponents().filter(c => c.island).map( + const components = options.getComponents() + 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')) + ) + return islands.map( (c) => { const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` const comment = createImportMagicComments(c) diff --git a/test/basic.test.ts b/test/basic.test.ts index a56c2f4397..b018c237de 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -57,6 +57,8 @@ describe('pages', () => { expect(html).toContain('This is a custom component with a named export.') // should apply attributes to client-only components expect(html).toContain('
') + // should render server-only components + expect(html).toContain('
server-only component
') // should register global components automatically expect(html).toContain('global component registered automatically') expect(html).toContain('global component via suffix') diff --git a/test/fixtures/basic/components/ServerOnlyComponent.server.vue b/test/fixtures/basic/components/ServerOnlyComponent.server.vue new file mode 100644 index 0000000000..d8ca539e3b --- /dev/null +++ b/test/fixtures/basic/components/ServerOnlyComponent.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/pages/index.vue b/test/fixtures/basic/pages/index.vue index a524f966f1..03d5ebea81 100644 --- a/test/fixtures/basic/pages/index.vue +++ b/test/fixtures/basic/pages/index.vue @@ -19,6 +19,7 @@ +