From 4855d347a4d086f0caeabdd6926420e526d63ec8 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 16 Jan 2023 00:05:14 +0000 Subject: [PATCH 1/7] feat(nuxt): support server components with extracted payloads --- .../nuxt/src/app/components/nuxt-island.ts | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 61e837fd3e4..3919f0337da 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -1,10 +1,9 @@ -import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue' +import { defineComponent, createStaticVNode, computed, watch } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' -import type { MetaObject } from '@nuxt/schema' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' -import { useHead, useNuxtApp } from '#app' +import { useAsyncData, useHead, useNuxtApp } from '#app' const pKey = '_islandPromises' @@ -27,9 +26,6 @@ export default defineComponent({ async setup (props) { const nuxtApp = useNuxtApp() const hashId = computed(() => hash([props.name, props.props, props.context])) - const html = ref('') - const cHead = ref({ link: [], style: [] }) - useHead(cHead) function _fetchComponent () { // TODO: Validate response @@ -41,27 +37,42 @@ export default defineComponent({ }) } - async function fetchComponent () { - nuxtApp[pKey] = nuxtApp[pKey] || {} - if (!nuxtApp[pKey][hashId.value]) { - nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => { - delete nuxtApp[pKey][hashId.value] + 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: [] + } }) } - const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value] - cHead.value.link = res.head.link - cHead.value.style = res.head.style - html.value = res.html - } + ) + + useHead(() => res.data.value!.head) if (process.client) { - watch(props, debounce(fetchComponent, 100)) + watch(props, debounce(() => res.execute({ _initial: true }), 100)) } - if (process.server || !nuxtApp.isHydrating) { - await fetchComponent() - } + await res - return () => createStaticVNode(html.value, 1) + return () => createStaticVNode(res.data.value!.html, 1) } }) From 052ec05f41de89f8e9b4c6f46f5a270dc3887dc7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 16 Jan 2023 00:11:46 +0000 Subject: [PATCH 2/7] test: add test --- test/basic.test.ts | 5 ++++- test/fixtures/basic/pages/random/[id].vue | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test/basic.test.ts b/test/basic.test.ts index 2f2e0706c5d..232e9e8413d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -898,7 +898,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*\}/ ) }) @@ -920,6 +920,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"]') @@ -927,6 +928,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) @@ -940,6 +942,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 ba4d1db5f97..2a1f24ada72 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 }} From 5da84823ea71eb2bda8a155238a140c48b13cf7b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 16 Jan 2023 20:54:28 +0000 Subject: [PATCH 3/7] fix: emit nuxt islands when prerendering --- packages/nuxt/src/app/components/nuxt-island.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 3919f0337da..6e428f8f793 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -1,9 +1,10 @@ import { defineComponent, createStaticVNode, computed, watch } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' +import { appendHeader } from 'h3' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' -import { useAsyncData, useHead, useNuxtApp } from '#app' +import { useAsyncData, useHead, useNuxtApp, useRequestEvent } from '#app' const pKey = '_islandPromises' @@ -27,6 +28,11 @@ export default defineComponent({ const nuxtApp = useNuxtApp() const hashId = computed(() => hash([props.name, props.props, props.context])) + if (process.server && process.env.prerender) { + // Emit the island component output during prerendering + appendHeader(useRequestEvent(), 'x-nitro-prerender', `/__nuxt_island/${props.name}:${hashId.value}`) + } + function _fetchComponent () { // TODO: Validate response return $fetch(`/__nuxt_island/${props.name}:${hashId.value}`, { From 6c51dc075b0034536f23f6fc8250b3765505a80b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 16 Jan 2023 21:01:03 +0000 Subject: [PATCH 4/7] fix: allow re-requesting data when props are updated --- packages/nuxt/src/app/components/nuxt-island.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 6e428f8f793..7852630577e 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -28,14 +28,16 @@ export default defineComponent({ const nuxtApp = useNuxtApp() const hashId = computed(() => hash([props.name, props.props, props.context])) - if (process.server && process.env.prerender) { - // Emit the island component output during prerendering - appendHeader(useRequestEvent(), 'x-nitro-prerender', `/__nuxt_island/${props.name}:${hashId.value}`) - } + 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(`/__nuxt_island/${props.name}:${hashId.value}`, { + return $fetch(url, { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined @@ -74,7 +76,7 @@ export default defineComponent({ useHead(() => res.data.value!.head) if (process.client) { - watch(props, debounce(() => res.execute({ _initial: true }), 100)) + watch(props, debounce(() => res.execute(), 100)) } await res From 481456cace58a18a2e88fb2da4c3a9ee5ab7d749 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 20 Jan 2023 11:14:31 +0000 Subject: [PATCH 5/7] refactor: extract payload improvements to only apply to server components --- .../nuxt/src/app/components/nuxt-island.ts | 56 +++++------- .../components/runtime/server-component.ts | 91 ++++++++++++++++++- 2 files changed, 110 insertions(+), 37 deletions(-) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 7852630577e..49a23653cda 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -1,10 +1,11 @@ -import { defineComponent, createStaticVNode, computed, watch } from 'vue' +import { defineComponent, createStaticVNode, computed, ref, watch } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' -import { appendHeader } from 'h3' +import type { MetaObject } from '@nuxt/schema' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' -import { useAsyncData, useHead, useNuxtApp, useRequestEvent } from '#app' +import { useHead, useNuxtApp, useRequestEvent } from '#app' +import { appendHeader } from 'h3' const pKey = '_islandPromises' @@ -30,6 +31,10 @@ export default defineComponent({ 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) { @@ -45,42 +50,27 @@ export default defineComponent({ }) } - 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: [] - } + async function fetchComponent () { + nuxtApp[pKey] = nuxtApp[pKey] || {} + if (!nuxtApp[pKey][hashId.value]) { + nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => { + delete nuxtApp[pKey][hashId.value] }) } - ) - - useHead(() => res.data.value!.head) + const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value] + cHead.value.link = res.head.link + cHead.value.style = res.head.style + html.value = res.html + } if (process.client) { - watch(props, debounce(() => res.execute(), 100)) + watch(props, debounce(fetchComponent, 100)) } - await res + if (process.server || !nuxtApp.isHydrating) { + await fetchComponent() + } - return () => createStaticVNode(res.data.value!.html, 1) + return () => createStaticVNode(html.value, 1) } }) diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 6e759be1583..660ac8707c8 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' +// eslint-disable-next-line import/no-restricted-paths +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) + } +}) From 8ef9bfe442f0a7686cbd6ff13ef2da97efc23301 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 20 Jan 2023 11:46:12 +0000 Subject: [PATCH 6/7] style: lint --- packages/nuxt/src/app/components/nuxt-island.ts | 2 +- packages/nuxt/src/components/runtime/server-component.ts | 2 +- packages/nuxt/src/core/nuxt.ts | 2 +- test/fixtures/basic/types.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 49a23653cda..b1080a7cf87 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -2,10 +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, useRequestEvent } from '#app' -import { appendHeader } from 'h3' const pKey = '_islandPromises' diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 660ac8707c8..3d61ccc7b7d 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -2,7 +2,7 @@ import { defineComponent, createStaticVNode, computed, h, watch } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' import { appendHeader } from 'h3' -// eslint-disable-next-line import/no-restricted-paths + import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import { useAsyncData, useHead, useNuxtApp, useRequestEvent } from '#app' diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index b3b7422cdd9..ec752c7d7ad 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/fixtures/basic/types.ts b/test/fixtures/basic/types.ts index 90302360920..88b83beacd7 100644 --- a/test/fixtures/basic/types.ts +++ b/test/fixtures/basic/types.ts @@ -6,7 +6,7 @@ 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 { defineNuxtConfig } from '~~/../../../packages/nuxt/config' import { useRouter } from '#imports' From 61c73cc6a195bec43b0a19838d7434c3f92c1036 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 20 Jan 2023 11:48:58 +0000 Subject: [PATCH 7/7] style: more linting --- test/fixtures/basic/types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/fixtures/basic/types.ts b/test/fixtures/basic/types.ts index 88b83beacd7..7f84779e31c 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' - import { isVue3 } from '#app' +import type { NavigateToOptions } from '~~/../../../packages/nuxt/dist/app/composables/router' import { defineNuxtConfig } from '~~/../../../packages/nuxt/config' import { useRouter } from '#imports'