Skip to content

Commit

Permalink
feat(nuxt): server-only components (nuxt#9972)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Jan 9, 2023
1 parent a3a0f00 commit 2d013c5
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 9 deletions.
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:

```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' }]))
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'))
)
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

0 comments on commit 2d013c5

Please sign in to comment.