diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index 6ee606fc07..17fddf0185 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -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. :: -::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 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. diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts index 522bfdea7b..35ab352f8e 100644 --- a/packages/nuxt/src/app/components/island-renderer.ts +++ b/packages/nuxt/src/app/components/island-renderer.ts @@ -1,4 +1,5 @@ -import { createBlock, defineComponent, h, Teleport } from 'vue' +import type { defineAsyncComponent } from 'vue' +import { defineComponent, createVNode } from 'vue' // @ts-ignore import * as islandComponents from '#build/components.islands.mjs' @@ -11,9 +12,8 @@ export default defineComponent({ required: true } }, - async setup (props) { - // TODO: https://github.com/vuejs/core/issues/6207 - const component = islandComponents[props.context.name] + setup (props) { + const component = islandComponents[props.context.name] as ReturnType if (!component) { throw createError({ @@ -21,13 +21,6 @@ export default defineComponent({ statusMessage: `Island component not found: ${JSON.stringify(component)}` }) } - - if (typeof component === 'object') { - await component.__asyncLoader?.() - } - - return () => [ - createBlock(Teleport as any, { to: 'nuxt-island' }, [h(component || 'span', props.context.props)]) - ] + return () => createVNode(component || 'span', props.context.props) } }) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 235d8195f6..60a2c8e23d 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -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 { hash } from 'ohash' import { appendHeader } from 'h3' @@ -30,10 +30,10 @@ export default defineComponent({ async setup (props) { const nuxtApp = useNuxtApp() const hashId = computed(() => hash([props.name, props.props, props.context])) - + const instance = getCurrentInstance()! const event = useRequestEvent() - const html = ref('') + const html = ref(process.client ? instance.vnode.el?.outerHTML ?? '
' : '
') const cHead = ref>>>({ link: [], style: [] }) useHead(cHead) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index a5eaf24bf2..769cb47f59 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -160,6 +160,7 @@ async function getIslandContext (event: H3Event): Promise { 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 ROOT_NODE_REGEX = new RegExp(`^<${appRootTag} id="${appRootId}">([\\s\\S]*)$`) const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html']) @@ -284,7 +285,7 @@ export default defineRenderHandler(async (event) => { renderedMeta.bodyScriptsPrepend, ssrContext.teleports?.body ]), - body: (process.env.NUXT_COMPONENT_ISLANDS && islandContext) ? [] : [_rendered.html], + body: [_rendered.html], bodyAppend: normalizeChunks([ process.env.NUXT_NO_SCRIPTS ? undefined @@ -318,7 +319,7 @@ export default defineRenderHandler(async (event) => { const islandResponse: NuxtIslandResponse = { id: islandContext.id, head, - html: ssrContext.teleports!['nuxt-island'].replace(//g, ''), + html: getServerComponentHTML(htmlContext.body), state: ssrContext.payload.state } @@ -428,3 +429,11 @@ function splitPayload (ssrContext: NuxtSSRContext) { 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] +} diff --git a/test/basic.test.ts b/test/basic.test.ts index 594a3d68a3..8ddcc37abd 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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": "
that was very long ...
3

hello world !!!

", + "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": "
This is a .server (20ms) async component that was very long ...
2
", + "state": {}, + } + `) + }) + it('renders pure components', async () => { const result: NuxtIslandResponse = await $fetch(withQuery('/__nuxt_island/PureComponent', { props: JSON.stringify({ diff --git a/test/fixtures/basic/components/AsyncServerComponent.server.vue b/test/fixtures/basic/components/AsyncServerComponent.server.vue new file mode 100644 index 0000000000..6bbee5b9ab --- /dev/null +++ b/test/fixtures/basic/components/AsyncServerComponent.server.vue @@ -0,0 +1,17 @@ + + + diff --git a/test/fixtures/basic/components/islands/LongAsyncComponent.vue b/test/fixtures/basic/components/islands/LongAsyncComponent.vue new file mode 100644 index 0000000000..b5e9c0e985 --- /dev/null +++ b/test/fixtures/basic/components/islands/LongAsyncComponent.vue @@ -0,0 +1,17 @@ + + + diff --git a/test/fixtures/basic/pages/islands.vue b/test/fixtures/basic/pages/islands.vue index ca229bc8a4..6ea3d6d5d7 100644 --- a/test/fixtures/basic/pages/islands.vue +++ b/test/fixtures/basic/pages/islands.vue @@ -7,6 +7,8 @@ const islandProps = ref({ }) const routeIslandVisible = ref(false) + +const count = ref(0) diff --git a/test/fixtures/basic/server/api/very-long-request.ts b/test/fixtures/basic/server/api/very-long-request.ts new file mode 100644 index 0000000000..472c16f458 --- /dev/null +++ b/test/fixtures/basic/server/api/very-long-request.ts @@ -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)) +}