fix(nuxt): directly render server components (#19605)

This commit is contained in:
Julien Huang 2023-03-20 22:47:06 +01:00 committed by GitHub
parent 3a971d0b36
commit 4671294229
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 114 additions and 21 deletions

View File

@ -297,10 +297,6 @@ Now you can register server-only components with the `.server` suffix and use th
Slots are not supported by server components in their current state of development. Slots are not supported by server components in their current state of development.
:: ::
::alert{type=info}
Please note that there is a known issue with using async code in a server component. It will lead to a hydration mismatch error on initial render. See [#18500](https://github.com/nuxt/nuxt/issues/18500#issuecomment-1403528142). Until there is a workaround, server-components must be synchronous.
::
### Paired with a `.client` component ### 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. 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.

View File

@ -1,4 +1,5 @@
import { createBlock, defineComponent, h, Teleport } from 'vue' import type { defineAsyncComponent } from 'vue'
import { defineComponent, createVNode } from 'vue'
// @ts-ignore // @ts-ignore
import * as islandComponents from '#build/components.islands.mjs' import * as islandComponents from '#build/components.islands.mjs'
@ -11,9 +12,8 @@ export default defineComponent({
required: true required: true
} }
}, },
async setup (props) { setup (props) {
// TODO: https://github.com/vuejs/core/issues/6207 const component = islandComponents[props.context.name] as ReturnType<typeof defineAsyncComponent>
const component = islandComponents[props.context.name]
if (!component) { if (!component) {
throw createError({ throw createError({
@ -21,13 +21,6 @@ export default defineComponent({
statusMessage: `Island component not found: ${JSON.stringify(component)}` statusMessage: `Island component not found: ${JSON.stringify(component)}`
}) })
} }
return () => createVNode(component || 'span', props.context.props)
if (typeof component === 'object') {
await component.__asyncLoader?.()
}
return () => [
createBlock(Teleport as any, { to: 'nuxt-island' }, [h(component || 'span', props.context.props)])
]
} }
}) })

View File

@ -1,4 +1,4 @@
import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue' import { defineComponent, createStaticVNode, computed, ref, watch, getCurrentInstance } from 'vue'
import { debounce } from 'perfect-debounce' import { debounce } from 'perfect-debounce'
import { hash } from 'ohash' import { hash } from 'ohash'
import { appendHeader } from 'h3' import { appendHeader } from 'h3'
@ -30,10 +30,10 @@ export default defineComponent({
async setup (props) { async setup (props) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const hashId = computed(() => hash([props.name, props.props, props.context])) const hashId = computed(() => hash([props.name, props.props, props.context]))
const instance = getCurrentInstance()!
const event = useRequestEvent() const event = useRequestEvent()
const html = ref<string>('') const html = ref<string>(process.client ? instance.vnode.el?.outerHTML ?? '<div></div>' : '<div></div>')
const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] }) const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead) useHead(cHead)

View File

@ -160,6 +160,7 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
const PAYLOAD_CACHE = (process.env.NUXT_PAYLOAD_EXTRACTION && process.env.prerender) ? new Map() : null // TODO: Use LRU cache const PAYLOAD_CACHE = (process.env.NUXT_PAYLOAD_EXTRACTION && process.env.prerender) ? new Map() : null // TODO: Use LRU cache
const PAYLOAD_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/ const PAYLOAD_URL_RE = /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/
const ROOT_NODE_REGEX = new RegExp(`^<${appRootTag} id="${appRootId}">([\\s\\S]*)</${appRootTag}>$`)
const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html']) const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html'])
@ -284,7 +285,7 @@ export default defineRenderHandler(async (event) => {
renderedMeta.bodyScriptsPrepend, renderedMeta.bodyScriptsPrepend,
ssrContext.teleports?.body ssrContext.teleports?.body
]), ]),
body: (process.env.NUXT_COMPONENT_ISLANDS && islandContext) ? [] : [_rendered.html], body: [_rendered.html],
bodyAppend: normalizeChunks([ bodyAppend: normalizeChunks([
process.env.NUXT_NO_SCRIPTS process.env.NUXT_NO_SCRIPTS
? undefined ? undefined
@ -318,7 +319,7 @@ export default defineRenderHandler(async (event) => {
const islandResponse: NuxtIslandResponse = { const islandResponse: NuxtIslandResponse = {
id: islandContext.id, id: islandContext.id,
head, head,
html: ssrContext.teleports!['nuxt-island'].replace(/<!--.*-->/g, ''), html: getServerComponentHTML(htmlContext.body),
state: ssrContext.payload.state state: ssrContext.payload.state
} }
@ -428,3 +429,11 @@ function splitPayload (ssrContext: NuxtSSRContext) {
payload: { data, prerenderedAt } payload: { data, prerenderedAt }
} }
} }
/**
* remove the root node from the html body
*/
function getServerComponentHTML (body: string[]): string {
const match = body[0].match(ROOT_NODE_REGEX)
return match ? match[1] : body[0]
}

View File

@ -1069,6 +1069,48 @@ describe('component islands', () => {
`) `)
}) })
it('render async component', async () => {
const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/LongAsyncComponent', {
props: JSON.stringify({
count: 3
})
}))
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
}
expect(result).toMatchInlineSnapshot(`
{
"head": {
"link": [],
"style": [],
},
"html": "<div>that was very long ... <div id=\\"long-async-component-count\\">3</div><p>hello world !!!</p></div>",
"state": {},
}
`)
})
it('render .server async component', async () => {
const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/AsyncServerComponent', {
props: JSON.stringify({
count: 2
})
}))
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
}
expect(result).toMatchInlineSnapshot(`
{
"head": {
"link": [],
"style": [],
},
"html": "<div> This is a .server (20ms) async component that was very long ... <div id=\\"async-server-component-count\\">2</div></div>",
"state": {},
}
`)
})
it('renders pure components', async () => { it('renders pure components', async () => {
const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent', { const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent', {
props: JSON.stringify({ props: JSON.stringify({

View File

@ -0,0 +1,17 @@
<template>
<div>
This is a .server (20ms) async component
{{ data }}
<div id="async-server-component-count">
{{ count }}
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
count: number
}>()
const { data } = await useFetch('/api/very-long-request')
</script>

View File

@ -0,0 +1,17 @@
<template>
<div>
{{ data }}
<div id="long-async-component-count">
{{ count }}
</div>
<p>hello world !!!</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
count: number
}>()
const { data } = await useFetch('/api/very-long-request')
</script>

View File

@ -7,6 +7,8 @@ const islandProps = ref({
}) })
const routeIslandVisible = ref(false) const routeIslandVisible = ref(false)
const count = ref(0)
</script> </script>
<template> <template>
@ -27,6 +29,16 @@ const routeIslandVisible = ref(false)
<button v-else @click="routeIslandVisible = true"> <button v-else @click="routeIslandVisible = true">
Show Show
</button> </button>
<p>async .server component</p>
<AsyncServerComponent :count="count" />
<div>
Async island component (20ms):
<NuxtIsland name="LongAsyncComponent" :props="{ count }" />
<button @click="count++">
add +1 to count
</button>
</div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,7 @@
export default defineEventHandler(async () => {
await timeout(20)
return 'that was very long ...'
})
function timeout (ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}