diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 61e837fd3e..b1080a7cf8 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -2,9 +2,10 @@ import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' import type { MetaObject } from '@nuxt/schema' +import { appendHeader } from 'h3' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' -import { useHead, useNuxtApp } from '#app' +import { useHead, useNuxtApp, useRequestEvent } from '#app' const pKey = '_islandPromises' @@ -27,13 +28,21 @@ export default defineComponent({ async setup (props) { const nuxtApp = useNuxtApp() const hashId = computed(() => hash([props.name, props.props, props.context])) + + const event = useRequestEvent() + const html = ref('') const cHead = ref({ link: [], style: [] }) useHead(cHead) function _fetchComponent () { + const url = `/__nuxt_island/${props.name}:${hashId.value}` + if (process.server && process.env.prerender) { + // Hint to Nitro to prerender the island component + appendHeader(event, 'x-nitro-prerender', url) + } // TODO: Validate response - return $fetch(`/__nuxt_island/${props.name}:${hashId.value}`, { + return $fetch(url, { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 6e759be158..3d61ccc7b7 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -1,16 +1,99 @@ -import { defineComponent, h } from 'vue' -// @ts-expect-error virtual import -import { NuxtIsland } from '#components' +import { defineComponent, createStaticVNode, computed, h, watch } from 'vue' +import { debounce } from 'perfect-debounce' +import { hash } from 'ohash' +import { appendHeader } from 'h3' + +import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' +import { useAsyncData, useHead, useNuxtApp, useRequestEvent } from '#app' + +const pKey = '_islandPromises' export const createServerComponent = (name: string) => { return defineComponent({ name, inheritAttrs: false, setup (_props, { attrs }) { - return () => h(NuxtIsland, { + return () => h(NuxtServerComponent, { name, props: attrs }) } }) } + +const NuxtServerComponent = defineComponent({ + name: 'NuxtServerComponent', + props: { + name: { + type: String, + required: true + }, + props: { + type: Object, + default: () => undefined + }, + context: { + type: Object, + default: () => ({}) + } + }, + async setup (props) { + const nuxtApp = useNuxtApp() + const hashId = computed(() => hash([props.name, props.props, props.context])) + + const event = useRequestEvent() + + function _fetchComponent () { + const url = `/__nuxt_island/${props.name}:${hashId.value}` + if (process.server && process.env.prerender) { + // Hint to Nitro to prerender the island component + appendHeader(event, 'x-nitro-prerender', url) + } + // TODO: Validate response + return $fetch(url, { + params: { + ...props.context, + props: props.props ? JSON.stringify(props.props) : undefined + } + }) + } + + const res = useAsyncData( + `${props.name}:${hashId.value}`, + async () => { + nuxtApp[pKey] = nuxtApp[pKey] || {} + if (!nuxtApp[pKey][hashId.value]) { + nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => { + delete nuxtApp[pKey][hashId.value] + }) + } + const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value] + return { + html: res.html, + head: { + link: res.head.link, + style: res.head.style + } + } + }, { + immediate: process.server || !nuxtApp.isHydrating, + default: () => ({ + html: '', + head: { + link: [], style: [] + } + }) + } + ) + + useHead(() => res.data.value!.head) + + if (process.client) { + watch(props, debounce(() => res.execute(), 100)) + } + + await res + + return () => createStaticVNode(res.data.value!.html, 1) + } +}) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index b3b7422cdd..ec752c7d7a 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -3,7 +3,7 @@ import { createHooks, createDebugger } from 'hookable' import type { Nuxt, NuxtOptions, NuxtHooks } from '@nuxt/schema' import type { LoadNuxtOptions } from '@nuxt/kit' import { loadNuxtConfig, nuxtCtx, installModule, addComponent, addVitePlugin, addWebpackPlugin, tryResolveModule, addPlugin } from '@nuxt/kit' -/* eslint-disable import/no-restricted-paths */ + import escapeRE from 'escape-string-regexp' import fse from 'fs-extra' import { withoutLeadingSlash } from 'ufo' diff --git a/test/basic.test.ts b/test/basic.test.ts index a9323ab191..d73e82aa7e 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -908,7 +908,7 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', () it('renders a payload', async () => { const payload = await $fetch('/random/a/_payload.js', { responseType: 'text' }) expect(payload).toMatch( - /export default \{data:\{hey:{[^}]*},rand_a:\[[^\]]*\]\},prerenderedAt:\d*\}/ + /export default \{data:\{hey:\{[^}]*\},rand_a:\[[^\]]*\],".*":\{html:".*server-only component.*",head:\{link:\[\],style:\[\]\}\}\},prerenderedAt:\d*\}/ ) }) @@ -930,6 +930,7 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', () // We are not triggering API requests in the payload expect(requests).not.toContain(expect.stringContaining('/api/random')) + expect(requests).not.toContain(expect.stringContaining('/__nuxt_island')) // requests.length = 0 await page.click('[href="/random/b"]') @@ -937,6 +938,7 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', () // We are not triggering API requests in the payload in client-side nav expect(requests).not.toContain('/api/random') + expect(requests).not.toContain(expect.stringContaining('/__nuxt_island')) // We are fetching a payload we did not prefetch expect(requests).toContain('/random/b/_payload.js' + importSuffix) @@ -950,6 +952,7 @@ describe.skipIf(process.env.NUXT_TEST_DEV || isWindows)('payload rendering', () // We are not triggering API requests in the payload in client-side nav expect(requests).not.toContain('/api/random') + expect(requests).not.toContain(expect.stringContaining('/__nuxt_island')) // We are not refetching payloads we've already prefetched // Note: we refetch on dev as urls differ between '' and '?import' diff --git a/test/fixtures/basic/pages/random/[id].vue b/test/fixtures/basic/pages/random/[id].vue index ba4d1db5f9..2a1f24ada7 100644 --- a/test/fixtures/basic/pages/random/[id].vue +++ b/test/fixtures/basic/pages/random/[id].vue @@ -12,6 +12,7 @@ Random (C) +
Random: {{ random }} diff --git a/test/fixtures/basic/types.ts b/test/fixtures/basic/types.ts index 9030236092..7f84779e31 100644 --- a/test/fixtures/basic/types.ts +++ b/test/fixtures/basic/types.ts @@ -5,9 +5,8 @@ import type { AppConfig } from '@nuxt/schema' import type { FetchError } from 'ofetch' import type { NavigationFailure, RouteLocationNormalizedLoaded, RouteLocationRaw, useRouter as vueUseRouter } from 'vue-router' -import type { NavigateToOptions } from '~~/../../../packages/nuxt/dist/app/composables/router' -// eslint-disable-next-line import/order import { isVue3 } from '#app' +import type { NavigateToOptions } from '~~/../../../packages/nuxt/dist/app/composables/router' import { defineNuxtConfig } from '~~/../../../packages/nuxt/config' import { useRouter } from '#imports'