Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

feat(nuxt): support server components with extracted payloads #10113

Merged
merged 8 commits into from
Jan 20, 2023
13 changes: 11 additions & 2 deletions packages/nuxt/src/app/components/nuxt-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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<string>('')
const cHead = ref<MetaObject>({ 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<NuxtIslandResponse>(`/__nuxt_island/${props.name}:${hashId.value}`, {
return $fetch<NuxtIslandResponse>(url, {
params: {
...props.context,
props: props.props ? JSON.stringify(props.props) : undefined
Expand Down
91 changes: 87 additions & 4 deletions packages/nuxt/src/components/runtime/server-component.ts
Original file line number Diff line number Diff line change
@@ -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<NuxtIslandResponse>(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)
}
})
2 changes: 1 addition & 1 deletion packages/nuxt/src/core/nuxt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 4 additions & 1 deletion test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*\}/
)
})

Expand All @@ -930,13 +930,15 @@ 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"]')
await page.waitForLoadState('networkidle')

// 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)
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/basic/pages/random/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<NuxtLink to="/random/c" prefetched-class="prefetched">
Random (C)
</NuxtLink>
<ServerOnlyComponent />
<br>

Random: {{ random }}
Expand Down
3 changes: 1 addition & 2 deletions test/fixtures/basic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down