Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(nuxt): use key to force server component re-rendering #19911

Merged
merged 14 commits into from Apr 20, 2023
Merged
45 changes: 40 additions & 5 deletions packages/nuxt/src/app/components/nuxt-island.ts
@@ -1,4 +1,5 @@
import { defineComponent, createStaticVNode, computed, ref, watch, getCurrentInstance } from 'vue'
import type { RendererNode } from 'vue'
import { defineComponent, createStaticVNode, computed, ref, watch, getCurrentInstance, h } from 'vue'
import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendHeader } from 'h3'
Expand Down Expand Up @@ -33,7 +34,7 @@ export default defineComponent({
const instance = getCurrentInstance()!
const event = useRequestEvent()

const html = ref<string>(process.client ? instance.vnode.el?.outerHTML ?? '<div></div>' : '<div></div>')
const html = ref<string>(process.client ? getFragmentHTML(instance?.vnode?.el).join('') ?? '<div></div>' : '<div></div>')
const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead)

Expand All @@ -51,7 +52,7 @@ export default defineComponent({
}
})
}

const key = ref(0)
async function fetchComponent () {
nuxtApp[pKey] = nuxtApp[pKey] || {}
if (!nuxtApp[pKey][hashId.value]) {
Expand All @@ -63,6 +64,7 @@ export default defineComponent({
cHead.value.link = res.head.link
cHead.value.style = res.head.style
html.value = res.html
key.value++
}

if (process.client) {
Expand All @@ -72,7 +74,40 @@ export default defineComponent({
if (process.server || !nuxtApp.isHydrating) {
await fetchComponent()
}

return () => createStaticVNode(html.value, 1)
return () => h((_, { slots }) => slots.default?.(), { key: key.value }, {
default: () => [createStaticVNode(html.value, 1)]
})
}
})

// TODO refactor with https://github.com/nuxt/nuxt/pull/19231
function getFragmentHTML (element: RendererNode | null) {
if (element) {
if (element.nodeName === '#comment' && element.nodeValue === '[') {
return getFragmentChildren(element)
}
return [element.outerHTML]
}
return []
}

function getFragmentChildren (element: RendererNode | null, blocks: string[] = []) {
if (element && element.nodeName) {
if (isEndFragment(element)) {
return blocks
} else if (!isStartFragment(element)) {
blocks.push(element.outerHTML)
}

getFragmentChildren(element.nextSibling, blocks)
}
return blocks
}

function isStartFragment (element: RendererNode) {
return element.nodeName === '#comment' && element.nodeValue === '['
}

function isEndFragment (element: RendererNode) {
return element.nodeName === '#comment' && element.nodeValue === ']'
}
93 changes: 3 additions & 90 deletions packages/nuxt/src/components/runtime/server-component.ts
@@ -1,102 +1,15 @@
import { defineComponent, createStaticVNode, computed, h, watch } from 'vue'
import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendHeader } from 'h3'

import { useHead } from '@unhead/vue'
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr'
import { useAsyncData } from '#app/composables/asyncData'

const pKey = '_islandPromises'
import { defineComponent, h } from 'vue'
import NuxtIsland from '#app/components/nuxt-island'

export const createServerComponent = (name: string) => {
return defineComponent({
name,
inheritAttrs: false,
setup (_props, { attrs }) {
return () => h(NuxtServerComponent, {
return () => h(NuxtIsland, {
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]
})
}
danielroe marked this conversation as resolved.
Show resolved Hide resolved
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)
}
})
25 changes: 21 additions & 4 deletions test/basic.test.ts
Expand Up @@ -317,6 +317,23 @@ describe('pages', () => {

await page.close()
})

it('/islands', async () => {
const page = await createPage('/islands')
await page.waitForLoadState('networkidle')
await page.locator('#increase-pure-component').click()
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
await page.waitForLoadState('networkidle')
expect(await page.locator('.box').innerHTML()).toContain('"number": 101,')
await page.locator('#count-async-server-long-async').click()
await Promise.all([
page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200),
page.waitForResponse(response => response.url().includes('/__nuxt_island/AsyncServerComponent') && response.status() === 200)
])
await page.waitForLoadState('networkidle')
expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1'))
expect(await page.locator('#long-async-component-count').innerHTML()).toContain('1')
})
})

describe('nuxt links', () => {
Expand Down Expand Up @@ -1053,7 +1070,7 @@ describe('component islands', () => {
const result: NuxtIslandResponse = await $fetch('/__nuxt_island/RouteComponent?url=/foo')

if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates') && (l.href.startsWith('_nuxt/components/islands/') && l.href.includes('_nuxt/components/islands/RouteComponent')))
}

expect(result).toMatchInlineSnapshot(`
Expand All @@ -1076,7 +1093,7 @@ describe('component islands', () => {
})
}))
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates') && (l.href.startsWith('_nuxt/components/islands/') && l.href.includes('_nuxt/components/islands/LongAsyncComponent')))
}
expect(result).toMatchInlineSnapshot(`
{
Expand All @@ -1097,7 +1114,7 @@ describe('component islands', () => {
})
}))
if (isDev()) {
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates'))
result.head.link = result.head.link.filter(l => !l.href.includes('@nuxt+ui-templates') && (l.href.startsWith('_nuxt/components/islands/') && l.href.includes('_nuxt/components/islands/AsyncServerComponent')))
}
expect(result).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -1198,7 +1215,7 @@ describe.skipIf(isDev() || 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:\[[^\]]*\],".*":\{html:".*server-only component.*",head:\{link:\[\],style:\[\]\}\}\},prerenderedAt:\d*\}/
/export default \{data:\{hey:\{[^}]*\},rand_a:\[[^\]]*\]\},prerenderedAt:\d*\}/
)
})

Expand Down
6 changes: 3 additions & 3 deletions test/fixtures/basic/pages/islands.vue
Expand Up @@ -18,15 +18,15 @@ const count = ref(0)
<NuxtIsland name="PureComponent" :props="islandProps" />
<NuxtIsland name="PureComponent" :props="islandProps" />
</div>
<button @click="islandProps.number++">
<button id="increase-pure-component" @click="islandProps.number++">
Increase
</button>
<hr>
Route island component:
<div v-if="routeIslandVisible" class="box">
<NuxtIsland name="RouteComponent" :context="{ url: '/test' }" />
</div>
<button v-else @click="routeIslandVisible = true">
<button v-else id="show-route" @click="routeIslandVisible = true">
Show
</button>

Expand All @@ -35,7 +35,7 @@ const count = ref(0)
<div>
Async island component (20ms):
<NuxtIsland name="LongAsyncComponent" :props="{ count }" />
<button @click="count++">
<button id="count-async-server-long-async" @click="count++">
add +1 to count
</button>
</div>
Expand Down