From 19fc2828fbe6c337fb30dfec2481b63a8cd3dd06 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 14 Jun 2023 10:09:27 +0100 Subject: [PATCH] perf(nuxt): use prerendered islands to serialise/revive payload (#21461) --- .../nuxt/src/app/components/nuxt-island.ts | 25 ++- .../src/app/plugins/revive-payload.client.ts | 34 +++- .../src/app/plugins/revive-payload.server.ts | 24 ++- .../components/runtime/server-component.ts | 157 +----------------- packages/nuxt/src/core/templates.ts | 1 + test/basic.test.ts | 39 ++++- test/fixtures/basic/pages/prefetch/index.vue | 7 + .../pages/prefetch/server-components.vue | 7 + test/utils.ts | 1 + 9 files changed, 117 insertions(+), 178 deletions(-) create mode 100644 test/fixtures/basic/pages/prefetch/index.vue create mode 100644 test/fixtures/basic/pages/prefetch/server-components.vue diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 4288fdaece..1ac05d8b22 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -42,6 +42,7 @@ export default defineComponent({ const event = useRequestEvent() const mounted = ref(false) onMounted(() => { mounted.value = true }) + const ssrHTML = ref(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '
' : '
') const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID()) const availableSlots = computed(() => { @@ -67,19 +68,37 @@ export default defineComponent({ return getSlotProps(ssrHTML.value) }) - function _fetchComponent () { - const url = `/__nuxt_island/${props.name}:${hashId.value}` + async function _fetchComponent () { + const key = `${props.name}:${hashId.value}` + if (nuxtApp.payload.data[key]) { return nuxtApp.payload.data[key] } + + const url = `/__nuxt_island/${key}` if (process.server && process.env.prerender) { // Hint to Nitro to prerender the island component appendResponseHeader(event, 'x-nitro-prerender', url) } // TODO: Validate response - return $fetch(url, { + const result = await $fetch(url, { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }) + nuxtApp.payload.data[key] = { + __nuxt_island: { + key, + ...(process.server && process.env.prerender) + ? {} + : { + params: { + ...props.context, + props: props.props ? JSON.stringify(props.props) : undefined + } + } + }, + ...result + } + return result } const key = ref(0) async function fetchComponent () { diff --git a/packages/nuxt/src/app/plugins/revive-payload.client.ts b/packages/nuxt/src/app/plugins/revive-payload.client.ts index 9402b9e19d..fa27861df6 100644 --- a/packages/nuxt/src/app/plugins/revive-payload.client.ts +++ b/packages/nuxt/src/app/plugins/revive-payload.client.ts @@ -1,16 +1,32 @@ import { reactive, ref, shallowReactive, shallowRef } from 'vue' import { definePayloadReviver, getNuxtClientPayload } from '#app/composables/payload' import { createError } from '#app/composables/error' -import { defineNuxtPlugin } from '#app/nuxt' +import { defineNuxtPlugin, useNuxtApp } from '#app/nuxt' -const revivers = { - NuxtError: (data: any) => createError(data), - EmptyShallowRef: (data: any) => shallowRef(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)), - EmptyRef: (data: any) => ref(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)), - ShallowRef: (data: any) => shallowRef(data), - ShallowReactive: (data: any) => shallowReactive(data), - Ref: (data: any) => ref(data), - Reactive: (data: any) => reactive(data) +// @ts-expect-error Virtual file. +import { componentIslands } from '#build/nuxt.config.mjs' + +const revivers: Record any> = { + NuxtError: data => createError(data), + EmptyShallowRef: data => shallowRef(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)), + EmptyRef: data => ref(data === '_' ? undefined : data === '0n' ? BigInt(0) : JSON.parse(data)), + ShallowRef: data => shallowRef(data), + ShallowReactive: data => shallowReactive(data), + Ref: data => ref(data), + Reactive: data => reactive(data) +} + +if (componentIslands) { + revivers.Island = ({ key, params }: any) => { + const nuxtApp = useNuxtApp() + if (!nuxtApp.isHydrating) { + nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}`, params ? { params } : {}).then((r) => { + nuxtApp.payload.data[key] = r + return r + }) + } + return null + } } export default defineNuxtPlugin({ diff --git a/packages/nuxt/src/app/plugins/revive-payload.server.ts b/packages/nuxt/src/app/plugins/revive-payload.server.ts index 8fd810ffe1..9196257e81 100644 --- a/packages/nuxt/src/app/plugins/revive-payload.server.ts +++ b/packages/nuxt/src/app/plugins/revive-payload.server.ts @@ -2,16 +2,22 @@ import { isReactive, isRef, isShallow, toRaw } from 'vue' import { definePayloadReducer } from '#app/composables/payload' import { isNuxtError } from '#app/composables/error' import { defineNuxtPlugin } from '#app/nuxt' -/* Defining a plugin that will be used by the Nuxt framework. */ -const reducers = { - NuxtError: (data: any) => isNuxtError(data) && data.toJSON(), - EmptyShallowRef: (data: any) => isRef(data) && isShallow(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')), - EmptyRef: (data: any) => isRef(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')), - ShallowRef: (data: any) => isRef(data) && isShallow(data) && data.value, - ShallowReactive: (data: any) => isReactive(data) && isShallow(data) && toRaw(data), - Ref: (data: any) => isRef(data) && data.value, - Reactive: (data: any) => isReactive(data) && toRaw(data) +// @ts-expect-error Virtual file. +import { componentIslands } from '#build/nuxt.config.mjs' + +const reducers: Record any> = { + NuxtError: data => isNuxtError(data) && data.toJSON(), + EmptyShallowRef: data => isRef(data) && isShallow(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')), + EmptyRef: data => isRef(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')), + ShallowRef: data => isRef(data) && isShallow(data) && data.value, + ShallowReactive: data => isReactive(data) && isShallow(data) && toRaw(data), + Ref: data => isRef(data) && data.value, + Reactive: data => isReactive(data) && toRaw(data) +} + +if (componentIslands) { + reducers.Island = data => data && data?.__nuxt_island } export default defineNuxtPlugin({ diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 46539c480d..4dcfa8cce5 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -1,166 +1,15 @@ -import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue' -import { debounce } from 'perfect-debounce' -import { hash } from 'ohash' -import { appendResponseHeader } from 'h3' - -import { useHead } from '@unhead/vue' -import { randomUUID } from 'uncrypto' -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' -import { getFragmentHTML, getSlotProps } from '#app/components/utils' - -const pKey = '_islandPromises' -const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/ -const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g -const SLOT_FALLBACK_RE = /
]*><\/div>(((?!
]*>)[\s\S])*)
]*><\/div>/g -const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/ - -let id = 0 -const getId = process.client ? () => 's' + (id--) : randomUUID +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, slots }) { - return () => h(NuxtServerComponent, { + return () => h(NuxtIsland, { name, props: attrs }, slots) } }) } - -const NuxtServerComponent = defineComponent({ - name: 'NuxtServerComponent', - props: { - name: { - type: String, - required: true - }, - props: { - type: Object, - default: () => undefined - }, - context: { - type: Object, - default: () => ({}) - } - }, - async setup (props, { slots }) { - const instance = getCurrentInstance()! - const uid = ref(getFragmentHTML(instance.vnode?.el)[0]?.match(SSR_UID_RE)?.[1] ?? getId()) - - const nuxtApp = useNuxtApp() - const mounted = ref(false) - const key = ref(0) - onMounted(() => { mounted.value = true }) - 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 - appendResponseHeader(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(async () => { - await res.execute() - key.value++ - if (process.client) { - // must await next tick for Teleport to work correctly with static node re-rendering - await nextTick() - } - setUid() - }, 100)) - } - - const slotProps = computed(() => { - return getSlotProps(res.data.value!.html) - }) - const availableSlots = computed(() => { - return [...res.data.value!.html.matchAll(SLOTNAME_RE)].map(m => m[1]) - }) - - const html = computed(() => { - const currentSlots = Object.keys(slots) - return res.data.value!.html - .replace(UID_ATTR, () => `nuxt-ssr-component-uid="${getId()}"`) - .replace(SLOT_FALLBACK_RE, (full, slotName, content) => { - // remove fallback to insert slots - if (currentSlots.includes(slotName)) { - return '' - } - return content - }) - }) - function setUid () { - uid.value = html.value.match(SSR_UID_RE)?.[1] ?? getId() as string - } - - await res - - if (process.server || !nuxtApp.isHydrating) { - setUid() - } - - return () => { - const nodes = [createVNode(Fragment, { - key: key.value - }, [createStaticVNode(html.value, 1)])] - if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) { - for (const slot in slots) { - if (availableSlots.value.includes(slot)) { - nodes.push(createVNode(Teleport, { to: process.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, { - default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data)) - })) - } - } - } - return nodes - } - } -}) diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index a54f1a7c60..66560a68b2 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -292,6 +292,7 @@ export const nuxtConfigTemplate = { return [ ...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`), `export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`, + `export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`, `export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}` ].join('\n\n') diff --git a/test/basic.test.ts b/test/basic.test.ts index c296e821b8..8449d69350 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -168,6 +168,8 @@ describe('pages', () => { await page.waitForLoadState('networkidle') expect(await page.innerText('body')).toContain('Composable | foo: auto imported from ~/composables/foo.ts') + await page.close() + await expectNoClientErrors('/proxy') }) @@ -446,8 +448,6 @@ describe('pages', () => { // test islands mounted client side with slot await page.locator('#show-island').click() - await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200) - await page.waitForLoadState('networkidle') expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR') await page.close() @@ -1202,10 +1202,36 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { }) }) -describe('prefetching', () => { +describe.skipIf(isDev() || isWindows || !isRenderingJson)('prefetching', () => { it('should prefetch components', async () => { await expectNoClientErrors('/prefetch/components') }) + + it('should prefetch server components', async () => { + await expectNoClientErrors('/prefetch/server-components') + }) + + it('should prefetch everything needed when NuxtLink is used', async () => { + const page = await createPage() + const requests: string[] = [] + + page.on('request', (req) => { + requests.push(req.url().replace(url('/'), '/').replace(/\.[^.]+\./g, '.')) + }) + + await page.goto(url('/prefetch')) + await page.waitForLoadState('networkidle') + + const snapshot = [...requests] + await page.click('[href="/prefetch/server-components"]') + await page.waitForLoadState('networkidle') + + expect(await page.innerHTML('#async-server-component-count')).toBe('34') + + expect(requests).toEqual(snapshot) + await page.close() + }) + it('should not prefetch certain dynamic imports by default', async () => { const html = await $fetch('/auth') // should not prefetch global components @@ -1596,6 +1622,13 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', ( await page.close() }) + + it.skipIf(!isRenderingJson)('should not include server-component HTML in payload', async () => { + const payload = await $fetch('/prefetch/server-components/_payload.json', { responseType: 'text' }) + const entries = Object.entries(parsePayload(payload)) + const [key, serialisedComponent] = entries.find(([key]) => key.startsWith('AsyncServerComponent')) || [] + expect(serialisedComponent).toEqual(key) + }) }) describe.skipIf(isWindows)('useAsyncData', () => { diff --git a/test/fixtures/basic/pages/prefetch/index.vue b/test/fixtures/basic/pages/prefetch/index.vue new file mode 100644 index 0000000000..e06910b636 --- /dev/null +++ b/test/fixtures/basic/pages/prefetch/index.vue @@ -0,0 +1,7 @@ + diff --git a/test/fixtures/basic/pages/prefetch/server-components.vue b/test/fixtures/basic/pages/prefetch/server-components.vue new file mode 100644 index 0000000000..cbb969ba48 --- /dev/null +++ b/test/fixtures/basic/pages/prefetch/server-components.vue @@ -0,0 +1,7 @@ + diff --git a/test/utils.ts b/test/utils.ts index cddfe671f4..25da63d522 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -102,6 +102,7 @@ const revivers = { EmptyRef: (data: any) => ref(JSON.parse(data)), ShallowRef: (data: any) => shallowRef(data), ShallowReactive: (data: any) => shallowReactive(data), + Island: (key: any) => key, Ref: (data: any) => ref(data), Reactive: (data: any) => reactive(data), // test fixture reviver only