From 6f3406e64096ef2d3c8225a8ee573c450b509def Mon Sep 17 00:00:00 2001 From: julien huang Date: Fri, 22 Dec 2023 23:02:25 +0100 Subject: [PATCH] wip --- .../{nuxt-island.ts => nuxt-island.client.ts} | 69 +++---- .../src/app/components/nuxt-island.server.ts | 169 ++++++++++++++++++ packages/nuxt/src/components/loader.ts | 3 +- packages/nuxt/src/components/module.ts | 8 +- .../components/runtime/server-component.ts | 10 +- packages/nuxt/src/components/transform.ts | 10 +- packages/nuxt/src/core/nuxt.ts | 11 +- 7 files changed, 221 insertions(+), 59 deletions(-) rename packages/nuxt/src/app/components/{nuxt-island.ts => nuxt-island.client.ts} (75%) create mode 100644 packages/nuxt/src/app/components/nuxt-island.server.ts diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.client.ts similarity index 75% rename from packages/nuxt/src/app/components/nuxt-island.ts rename to packages/nuxt/src/app/components/nuxt-island.client.ts index 0a9ec90077..c1e2441813 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.client.ts @@ -12,7 +12,7 @@ import { join } from 'pathe' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import { useNuxtApp, useRuntimeConfig } from '../nuxt' -import { prerenderRoutes, useRequestEvent } from '../composables/ssr' +import { useRequestEvent } from '../composables/ssr' import { getFragmentHTML, getSlotProps } from './utils' // @ts-expect-error virtual file @@ -25,9 +25,9 @@ const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g const SLOT_FALLBACK_RE = /
]*><\/div>(((?!
]*>)[\s\S])*)
]*><\/div>/g let id = 0 -const getId = import.meta.client ? () => (id++).toString() : randomUUID +const getId = () => (id++).toString() -const components = import.meta.client ? new Map() : undefined +const components = new Map() async function loadComponents (source = '/', paths: Record) { const promises = [] @@ -86,10 +86,9 @@ export default defineComponent({ const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key]) => !key.startsWith('data-v-'))) : {}) const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source])) const instance = getCurrentInstance()! - const event = useRequestEvent() // TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved - const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch + const eventFetch = import.meta.dev ? $fetch.raw : globalThis.fetch const mounted = ref(false) onMounted(() => { mounted.value = true }) @@ -97,9 +96,7 @@ export default defineComponent({ nuxtApp.payload.data[key] = { __nuxt_island: { key, - ...(import.meta.server && import.meta.prerender) - ? {} - : { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }, + params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } , result: { chunks: result.chunks, props: result.props, @@ -120,11 +117,7 @@ export default defineComponent({ teleports: rawPayload.teleports } - const ssrHTML = ref('') - - if (import.meta.client) { - ssrHTML.value = getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || '' - } + const ssrHTML = ref(getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || '') const slotProps = computed(() => getSlotProps(ssrHTML.value)) const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId()) @@ -134,7 +127,7 @@ export default defineComponent({ const currentSlots = Object.keys(slots) let html = ssrHTML.value - if (import.meta.client && !canLoadClientComponent.value) { + if (!canLoadClientComponent.value) { for (const [key, value] of Object.entries(nonReactivePayload.teleports || {})) { html = html.replace(new RegExp(`
]*nuxt-ssr-client="${key}"[^>]*>`), (full) => { return full + value @@ -165,24 +158,14 @@ export default defineComponent({ const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json` - if (import.meta.server && import.meta.prerender) { - // Hint to Nitro to prerender the island component - nuxtApp.runWithContext(() => prerenderRoutes(url)) - } // TODO: Validate response // $fetch handles the app.baseURL in dev - const r = await eventFetch(withQuery(((import.meta.dev && import.meta.client) || props.source) ? url : joinURL(config.app.baseURL ?? '', url), { + const r = await eventFetch(withQuery((import.meta.dev || props.source) ? url : joinURL(config.app.baseURL ?? '', url), { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined })) - const result = import.meta.server || !import.meta.dev ? await r.json() : (r as FetchResponse)._data - // TODO: support passing on more headers - if (import.meta.server && import.meta.prerender) { - const hints = r.headers.get('x-nitro-prerender') - if (hints) { - appendResponseHeader(event, 'x-nitro-prerender', hints) - } - } + const result = import.meta.dev ? (r as FetchResponse)._data : await r.json() + setPayload(key, result) return result } @@ -204,7 +187,7 @@ export default defineComponent({ key.value++ error.value = null - if (selectiveClient && import.meta.client) { + if (selectiveClient) { if (canLoadClientComponent.value && res.chunks) { await loadComponents(props.source, res.chunks) } @@ -213,10 +196,9 @@ export default defineComponent({ nonReactivePayload.teleports = res.teleports nonReactivePayload.chunks = res.chunks - if (import.meta.client) { - // must await next tick for Teleport to work correctly with static node re-rendering - await nextTick() - } + // must await next tick for Teleport to work correctly with static node re-rendering + await nextTick() + setUid() } catch (e) { error.value = e @@ -233,13 +215,11 @@ export default defineComponent({ }) } - if (import.meta.client) { - watch(props, debounce(() => fetchComponent(), 100)) - } - - if (import.meta.client && !nuxtApp.isHydrating && props.lazy) { + watch(props, debounce(() => fetchComponent(), 100)) + + if (!nuxtApp.isHydrating && props.lazy) { fetchComponent() - } else if (import.meta.server || !nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) { + } else if (!nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) { await fetchComponent() } else if (selectiveClient && canLoadClientComponent.value && nonReactivePayload.chunks) { await loadComponents(props.source, nonReactivePayload.chunks) @@ -253,22 +233,15 @@ export default defineComponent({ key: key.value }, [h(createStaticVNode(html.value || '
', 1))])] - if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server) && html.value) { + if (uid.value && (mounted.value || nuxtApp.isHydrating) && html.value) { for (const slot in slots) { if (availableSlots.value.includes(slot)) { - nodes.push(createVNode(Teleport, { to: import.meta.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, { + nodes.push(createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` }, { default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data)) })) } } - if (import.meta.server) { - for (const [id, html] of Object.entries(nonReactivePayload.teleports ?? {})) { - nodes.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { - default: () => [createStaticVNode(html, 1)] - })) - } - } - if (selectiveClient && import.meta.client && canLoadClientComponent.value) { + if (selectiveClient && canLoadClientComponent.value) { for (const [id, props] of Object.entries(nonReactivePayload.props ?? {})) { const component = components!.get(id.split('-')[0])! const vnode = createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-client="${id}"]` }, { diff --git a/packages/nuxt/src/app/components/nuxt-island.server.ts b/packages/nuxt/src/app/components/nuxt-island.server.ts new file mode 100644 index 0000000000..e83ef38d99 --- /dev/null +++ b/packages/nuxt/src/app/components/nuxt-island.server.ts @@ -0,0 +1,169 @@ +import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, h, ref, } from 'vue' +import { hash } from 'ohash' +import { appendResponseHeader } from 'h3' +import { useHead } from '@unhead/vue' +import { randomUUID } from 'uncrypto' +import { joinURL, withQuery } from 'ufo' + +// eslint-disable-next-line import/no-restricted-paths +import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' +import { useNuxtApp, useRuntimeConfig } from '../nuxt' +import { prerenderRoutes, useRequestEvent } from '../composables/ssr' +import { getSlotProps } from './utils' + +// @ts-expect-error virtual file +import { remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs' + +const pKey = '_islandPromises' +const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/ +const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/ +const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g + +export default defineComponent({ + name: 'NuxtIsland', + props: { + name: { + type: String, + required: true + }, + lazy: Boolean, + props: { + type: Object, + default: () => undefined + }, + context: { + type: Object, + default: () => ({}) + }, + source: { + type: String, + default: () => undefined + }, + dangerouslyLoadClientComponents: { + type: Boolean, + default: false + } + }, + async setup(props, { slots }) { + const error = ref(null) + const config = useRuntimeConfig() + const nuxtApp = useNuxtApp() + const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key]) => !key.startsWith('data-v-'))) : {}) + const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source])) + const event = useRequestEvent() + + + function setPayload(key: string, result: NuxtIslandResponse) { + nuxtApp.payload.data[key] = { + __nuxt_island: { + key, + ...(import.meta.prerender) + ? {} + : { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }, + result: { + chunks: result.chunks, + props: result.props, + teleports: result.teleports + } + }, + ...result + } + } + const nonReactivePayload: Pick = { + chunks: {}, + props: {}, + teleports: {} + } + + const ssrHTML = ref('') + + const slotProps = computed(() => getSlotProps(ssrHTML.value)) + const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID()) + const availableSlots = computed(() => [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1])) + + function setUid() { + uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID() as string + } + + const cHead = ref>>>({ link: [], style: [] }) + useHead(cHead) + + async function _fetchComponent() { + const key = `${props.name}_${hashId.value}` + + if (nuxtApp.payload.data[key]?.html) { return nuxtApp.payload.data[key] } + + const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json` + + if (import.meta.prerender) { + // Hint to Nitro to prerender the island component + nuxtApp.runWithContext(() => prerenderRoutes(url)) + } + + // TODO: Validate response + const r = await event.fetch(withQuery((props.source) ? url : joinURL(config.app.baseURL ?? '', url), { + ...props.context, + props: props.props ? JSON.stringify(props.props) : undefined + })) + const result = await r.json() + // TODO: support passing on more headers + if (import.meta.prerender) { + const hints = r.headers.get('x-nitro-prerender') + if (hints) { + appendResponseHeader(event, 'x-nitro-prerender', hints) + } + } + setPayload(key, result) + return result + } + + async function fetchComponent(force = false) { + nuxtApp[pKey] = nuxtApp[pKey] || {} + if (!nuxtApp[pKey][uid.value]) { + nuxtApp[pKey][uid.value] = _fetchComponent().finally(() => { + delete nuxtApp[pKey]![uid.value] + }) + } + try { + const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value] + cHead.value.link = res.head.link + cHead.value.style = res.head.style + ssrHTML.value = res.html.replace(UID_ATTR, () => { + return `nuxt-ssr-component-uid="${randomUUID()}"` + }) + nonReactivePayload.teleports = res.teleports + nonReactivePayload.chunks = res.chunks + + setUid() + } catch (e) { + error.value = e + } + } + + await fetchComponent() + + return () => { + if (!ssrHTML.value || error.value) { + return [slots.fallback?.({ error: error.value }) ?? createVNode('div')] + } + const nodes = [createVNode(Fragment, {}, [h(createStaticVNode(ssrHTML.value || '
', 1))])] + + // render slots and teleports + if (uid.value && ssrHTML.value) { + for (const slot in slots) { + if (availableSlots.value.includes(slot)) { + nodes.push(createVNode(Teleport, { to: `uid=${uid.value};slot=${slot}` }, { + default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data)) + })) + } + } + for (const [id, html] of Object.entries(nonReactivePayload.teleports ?? {})) { + nodes.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { + default: () => [createStaticVNode(html, 1)] + })) + } + } + return nodes + } + } +}) diff --git a/packages/nuxt/src/components/loader.ts b/packages/nuxt/src/components/loader.ts index 2c86a709cb..9fa6e9403f 100644 --- a/packages/nuxt/src/components/loader.ts +++ b/packages/nuxt/src/components/loader.ts @@ -57,8 +57,9 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { const isServerOnly = !component._raw && component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client') if (isServerOnly) { + imports.add(genImport(`#app/components/nuxt-island.${options.mode}`, 'NuxtIsland')) imports.add(genImport(serverComponentRuntime, [{ name: 'createServerComponent' }])) - imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(name)})`) + imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(name)}, NuxtIsland)`) if (!options.experimentalComponentIslands) { logger.warn(`Standalone server components (\`${name}\`) are not yet supported without enabling \`experimental.componentIslands\`.`) } diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index bcc6fb259b..bf73c9c0c0 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -130,11 +130,11 @@ export default defineNuxtModule({ const unpluginServer = createTransformPlugin(nuxt, getComponents, 'server') const unpluginClient = createTransformPlugin(nuxt, getComponents, 'client') - addVitePlugin(() => unpluginServer.vite(), { server: true, client: false }) - addVitePlugin(() => unpluginClient.vite(), { server: false, client: true }) + addVitePlugin(() => unpluginServer.vite({ bundle: 'server'}), { server: true, client: false }) + addVitePlugin(() => unpluginClient.vite({ bundle: 'client'}), { server: false, client: true }) - addWebpackPlugin(() => unpluginServer.webpack(), { server: true, client: false }) - addWebpackPlugin(() => unpluginClient.webpack(), { server: false, client: true }) + addWebpackPlugin(() => unpluginServer.webpack({ bundle: 'server'}), { server: true, client: false }) + addWebpackPlugin(() => unpluginClient.webpack({ bundle: 'client'}), { server: false, client: true }) // Do not prefetch global components chunks nuxt.hook('build:manifest', (manifest) => { diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 3a2463b09d..0df8c7c032 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -1,8 +1,12 @@ -import { defineComponent, h, ref } from 'vue' -import NuxtIsland from '#app/components/nuxt-island' +import { DefineComponent, defineComponent, h, ref } from 'vue' +/** + * Since NuxtIsland is split into a server and client file + * we need to pass it as an argument to createServerComponent + * It is normally injected by transform plugins + */ /*@__NO_SIDE_EFFECTS__*/ -export const createServerComponent = (name: string) => { +export const createServerComponent = (name: string, NuxtIsland: DefineComponent) => { return defineComponent({ name, inheritAttrs: false, diff --git a/packages/nuxt/src/components/transform.ts b/packages/nuxt/src/components/transform.ts index 39258224e3..1b5ba95592 100644 --- a/packages/nuxt/src/components/transform.ts +++ b/packages/nuxt/src/components/transform.ts @@ -47,7 +47,7 @@ export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT }) } - return createUnplugin(() => ({ + return createUnplugin(({bundle} : {bundle: 'server'|'client'}) => ({ name: 'nuxt:components:imports', transformInclude (id) { id = normalize(id) @@ -88,10 +88,16 @@ export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT } } else if (mode === 'server' || mode === 'server,async') { const name = query.nuxt_component_name + console.log( [ + `import NuxtIsland from "#app/components/nuxt-island.${bundle}"`, + `import { createServerComponent } from ${JSON.stringify(serverComponentRuntime)}`, + `export default createServerComponent(${JSON.stringify(name)}, NuxtIsland)` + ].join('\n')) return { code: [ + `import NuxtIsland from "#app/components/nuxt-island.${bundle}"`, `import { createServerComponent } from ${JSON.stringify(serverComponentRuntime)}`, - `export default createServerComponent(${JSON.stringify(name)})` + `export default createServerComponent(${JSON.stringify(name)}, NuxtIsland)` ].join('\n'), map: null } diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index da541b99d8..0ac1eaadf7 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -294,7 +294,16 @@ async function initNuxt (nuxt: Nuxt) { addComponent({ name: 'NuxtIsland', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/nuxt-island') + filePath: resolve(nuxt.options.appDir, 'components/nuxt-island.server'), + mode: 'server', + _raw: true + }) + addComponent({ + name: 'NuxtIsland', + priority: 10, // built-in that we do not expect the user to override + filePath: resolve(nuxt.options.appDir, 'components/nuxt-island.client'), + mode: 'client', + _raw: true }) if (!nuxt.options.ssr) {