Skip to content
This repository was archived by the owner on Apr 6, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
danielroe marked this conversation as resolved.

```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]
<template>
<div>
<!--
this will automatically be rendered on the server, meaning your markdown parsing + highlighting
libraries are not included in your client bundle.
-->
<HighlightedMarkdown markdown="# Headline" />
</div>
</template>
```

::

### 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/
Expand All @@ -227,6 +268,10 @@ This feature only works with Nuxt auto-imports and `#components` imports. Explic
</template>
```

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

## `<DevOnly>` Component

Nuxt provides the `<DevOnly>` component to render a component only during development.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
const props = defineProps({ foo: Number })
const colors = [
'red',
'blue',
'yellow'
]
const color = colors[(props.foo ?? 1) % colors.length]
</script>

<template>
<section class="flex flex-col gap-1 p-4">
I'm a server component with some reactive state: {{ foo }}
</section>
</template>

<style scoped>
.flex {
color: v-bind(color)
}
</style>
30 changes: 25 additions & 5 deletions packages/nuxt/src/components/loader.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,6 +11,7 @@ interface LoaderOptions {
mode: 'server' | 'client'
sourcemap?: boolean
transform?: ComponentsOptions['transform']
experimentalComponentIslands?: boolean
}

function isVueTemplate (id: string) {
Expand Down Expand Up @@ -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',
Expand All @@ -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' }]))
Comment thread
danielroe marked this conversation as resolved.
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' }]))
Expand Down Expand Up @@ -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 <ServerPlaceholder> (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)
}
6 changes: 4 additions & 2 deletions packages/nuxt/src/components/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ export default defineNuxtModule<ComponentsOptions>({
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({
Expand All @@ -212,7 +213,8 @@ export default defineNuxtModule<ComponentsOptions>({
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({
Expand Down
16 changes: 16 additions & 0 deletions packages/nuxt/src/components/runtime/server-component.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
})
}
8 changes: 7 additions & 1 deletion packages/nuxt/src/components/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
export const componentsIslandsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
// 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'))
Comment thread
danielroe marked this conversation as resolved.
)
return islands.map(
(c) => {
const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']`
const comment = createImportMagicComments(c)
Expand Down
2 changes: 2 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div style="color:red;" class="client-only"></div>')
// should render server-only components
expect(html).toContain('<div class="server-only" style="background-color:gray;"> server-only component </div>')
// should register global components automatically
expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix')
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/basic/components/ServerOnlyComponent.server.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div>
server-only component
</div>
</template>
1 change: 1 addition & 0 deletions test/fixtures/basic/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<component :is="`test${'-'.toString()}global`" />
<component :is="`with${'-'.toString()}suffix`" />
<ClientWrapped ref="clientRef" style="color: red;" class="client-only" />
<ServerOnlyComponent class="server-only" style="background-color: gray;" />
</div>
</template>

Expand Down